diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2e424c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: release + +# Pushing a version tag turns the GoReleaser config (.goreleaser.yaml) into a +# GitHub release with the archives, Linux packages (deb, rpm, apk), checksums, +# SBOMs and a cosign signature, and pushes the multi-arch container image to +# GHCR. Pull requests and pushes to main run `goreleaser check` only, so a +# config that would fail a real release is caught long before the tag. + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Fast gate on every PR and push: the config parses and is valid for the + # installed GoReleaser version. No artifacts are built here. + check: + if: github.ref_type != 'tag' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check + + # The real release, only on a version tag. + release: + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + packages: write # push the image to ghcr.io + id-token: write # keyless cosign signing + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # Build and ship the linux/arm64 image from the amd64 runner. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tools GoReleaser shells out to for signing and SBOMs. + - uses: sigstore/cosign-installer@v3 + - uses: anchore/sbom-action/download-syft@v0 + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + # Creating the release and pushing the GHCR image use the built-in + # token. The package-manager publish steps each read their own secret; + # any that is unset leaves that manager skipped (the artifact is still + # produced), so the release never fails for a tap that is not set up. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7835326..1edc2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /dbrest /cmd/dbrest/dbrest +# GoReleaser build output +dist/ + # Local databases *.sqlite *.sqlite-shm diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7c18eb4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,189 @@ +# GoReleaser turns one tag push into everything a user might install from: raw +# archives, Linux packages (deb, rpm, apk), a multi-arch container image, and +# entries for the package managers (Homebrew, Scoop). `git tag vX.Y.Z && git +# push --tags` fans out to all of them through .github/workflows/release.yml. +# +# Publish steps that push to a repository we do not own yet (the Homebrew tap, +# the Scoop bucket) self-disable when their token is absent. A release with no +# extra secrets still produces every downloadable artifact and the container +# image; each manager lights up the moment its repository and token exist. +version: 2 + +project_name: dbrest + +before: + # Only fetch modules; never `go mod tidy` during a release, the tree is kept + # tidy in CI. + hooks: + - go mod download + +builds: + - id: dbrest + binary: dbrest + main: ./cmd/dbrest + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .CommitDate }} + mod_timestamp: "{{ .CommitTimestamp }}" + targets: + - linux_amd64 + - linux_arm64 + - linux_arm_7 + - linux_386 + - darwin_amd64 + - darwin_arm64 + - windows_amd64 + - windows_arm64 + - freebsd_amd64 + - freebsd_arm64 + +archives: + # tar.gz everywhere except a zip on Windows. + - id: default + name_template: "dbrest_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + +nfpms: + # One nfpm definition emits the deb, rpm, and apk for every Linux build. The + # package is the binary and its license; the database it serves is whatever + # the operator points db-uri at, so there are no package dependencies. + - id: linux-packages + package_name: dbrest + file_name_template: "{{ .ConventionalFileName }}" + vendor: tamnd + homepage: https://github.com/tamnd/dbrest + maintainer: Duc-Tam Nguyen + description: A PostgREST-compatible REST API for any database. + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + section: utils + contents: + - src: ./LICENSE + dst: /usr/share/doc/dbrest/LICENSE + +dockers_v2: + # One multi-platform image built with buildx. GoReleaser stages the prebuilt + # binaries under per-platform directories in the build context and the + # Dockerfile copies the right one through $TARGETPLATFORM. + - images: + - ghcr.io/tamnd/dbrest + tags: + - "{{ .Version }}" + - latest + dockerfile: Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: "A PostgREST-compatible REST API for any database" + org.opencontainers.image.url: "https://github.com/tamnd/dbrest" + org.opencontainers.image.source: "https://github.com/tamnd/dbrest" + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.licenses: "Apache-2.0" + +homebrew_casks: + # Homebrew cask pushed to the tap repository. Self-disables until + # HOMEBREW_TAP_GITHUB_TOKEN (a PAT with write to tamnd/homebrew-tap) is set, + # so a tokenless release still writes the cask into dist for inspection. + - name: dbrest + repository: + owner: tamnd + name: homebrew-tap + token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}' + directory: Casks + homepage: https://github.com/tamnd/dbrest + description: A PostgREST-compatible REST API for any database + skip_upload: '{{ if envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' + commit_author: + name: Duc-Tam Nguyen + email: tamnd87@gmail.com + +scoops: + # Scoop manifest for Windows, pushed to the bucket repository. + - repository: + owner: tamnd + name: scoop-bucket + token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}' + homepage: https://github.com/tamnd/dbrest + description: A PostgREST-compatible REST API for any database + license: Apache-2.0 + skip_upload: '{{ if envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' + commit_author: + name: Duc-Tam Nguyen + email: tamnd87@gmail.com + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +sboms: + # A CycloneDX SBOM per archive via syft; the release workflow installs it. + - id: archive + artifacts: archive + +signs: + # Keyless cosign signature over the checksum file. It runs only on a real + # release in CI, where the workflow grants the OIDC token cosign needs. + - cmd: cosign + certificate: "${artifact}.pem" + args: + - sign-blob + - "--output-certificate=${certificate}" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + output: true + +docker_signs: + - cmd: cosign + artifacts: manifests + args: + - sign + - "${artifact}@${digest}" + - "--yes" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - Merge pull request + - Merge branch + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Other + order: 999 + +release: + github: + owner: tamnd + name: dbrest + draft: false + prerelease: auto diff --git a/Dockerfile b/Dockerfile index 19614d2..f49fee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,31 @@ -# Build the dbrest binary in a Go 1.26 builder, then copy it into a minimal -# Alpine image. The binary is statically linked (CGO_ENABLED=0) so it runs on -# any Linux base image without a matching libc. -FROM docker.io/library/golang:1.26-alpine AS builder -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /bin/dbrest ./cmd/dbrest - -FROM docker.io/library/alpine:3.21 -COPY --from=builder /bin/dbrest /usr/local/bin/dbrest -EXPOSE 3001 -ENTRYPOINT ["/usr/local/bin/dbrest"] +# Consumed by GoReleaser: it copies the already cross-compiled binary out of the +# build context rather than compiling, so the image build is fast and uses the +# same static binary every other artifact ships. +# +# GoReleaser builds one multi-platform image with buildx and stages each +# platform's binary under a $TARGETPLATFORM directory (e.g. linux/amd64/) in the +# build context, so the COPY line selects the right one through the automatic +# TARGETPLATFORM build arg. +FROM alpine:3.21 + +ARG TARGETPLATFORM + +# ca-certificates for TLS to the database; tzdata for sane timestamps. +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -H -u 10001 dbrest + +COPY $TARGETPLATFORM/dbrest /usr/bin/dbrest + +USER dbrest + +# 3000 is the API; 3001 is the admin server (/live, /ready, /metrics) when it is +# enabled. Configure the server with a mounted config file or the DBREST_* +# environment, for example: +# +# docker run -p 3000:3000 \ +# -e DBREST_DB_BACKEND=postgres \ +# -e DBREST_DB_URI="postgres://web@db/app" \ +# ghcr.io/tamnd/dbrest +EXPOSE 3000 3001 + +ENTRYPOINT ["/usr/bin/dbrest"] diff --git a/README.md b/README.md index 574865c..0ce3608 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,25 @@ Early, and built subsystem by subsystem against a complete design spec. What wor The capability model, the backend SPI, and the error envelope are in place. The PostgreSQL dialect and its version-computed capabilities have landed (`backend/postgres`), the reference oracle the conformance harness diffs against. The MySQL/MariaDB dialect has landed too (`backend/mysql`), the first real divergence from the oracle: an explicit IS NULL sort key for NULL placement, a no-conflict-target upsert with a no-op ignore, restricted CAST targets, `REGEXP_LIKE`, and MATCH/AGAINST boolean-mode full text. The SQL Server (T-SQL) dialect has landed as well (`backend/sqlserver`), the quirkiest on syntax and the closest to the oracle on the security model: bracket-quoted identifiers, named `@pN` placeholders, OFFSET/FETCH paging that injects an ORDER BY when the client gave none, a CASE NULL sort key, OUTPUT in place of RETURNING, a multi-statement upsert that the data plane drives, and CONTAINS/FREETEXT full text, with native roles, RLS, and a session-context store. Each driver data plane (Execute and introspection over a live server) is a follow-on slice, since it needs a running database to test. The MongoDB backend has landed too (`backend/mongo`), and it is the one engine that does not use the SQL compiler: it lowers a filter to a `$match` query document, a read to a `$match`/`$sort`/`$skip`/`$limit`/`$project` pipeline, casts to `$convert`, and NULLS placement to an `$addFields` sort key, with the array and range operators Unsupported and the security model emulated app-side. Its live driver data plane (`$lookup`/`$graphLookup` embedding, writes, sampling-based introspection) is the follow-on slice. Each backend joins the conformance harness by adding its fixture and a CI job, with no harness changes. +## Install + +Every release ships prebuilt binaries, Linux packages, and a container image. Grab the archive for your platform from the [releases page](https://github.com/tamnd/dbrest/releases), or pull the image: + +```sh +docker run -p 3000:3000 \ + -e DBREST_DB_BACKEND=sqlite \ + -e DBREST_DB_URI='file:/data/example.sqlite' \ + ghcr.io/tamnd/dbrest +``` + +On Debian or Red Hat, install the `.deb` or `.rpm` from the release. With the Go toolchain: + +```sh +go install github.com/tamnd/dbrest/cmd/dbrest@latest +``` + +The Homebrew tap and Scoop bucket come online as those repositories are set up. Each release also carries a checksums file, a CycloneDX SBOM per archive, and a keyless cosign signature. + ## Quick start Write a config file naming the backend and the database: diff --git a/cmd/dbrest/cli.go b/cmd/dbrest/cli.go index e6b1d4f..f7883de 100644 --- a/cmd/dbrest/cli.go +++ b/cmd/dbrest/cli.go @@ -28,14 +28,37 @@ func resolveConfigPath(flagPath string, args []string) (string, error) { return args[0], nil } -// versionString is the module version when built with one, "dev" otherwise. +// Build metadata. The release pipeline stamps these with -ldflags -X; an +// unstamped build (go build, go run) leaves the defaults and falls back to the +// module version recorded in the build info. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +// versionString is the stamped release version, the module version when built +// from a checkout that carries one, or "dev". func versionString() string { + if version != "dev" { + return version + } if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { return bi.Main.Version } return "dev" } +// versionLine is the full --version line. It adds the commit and build date +// when the binary was stamped by the release pipeline. +func versionLine() string { + v := "dbrest " + versionString() + if commit != "none" || date != "unknown" { + v += fmt.Sprintf(" (commit %s, built %s)", commit, date) + } + return v +} + // probeReady asks a running instance's admin server whether it is ready, the // --ready verb orchestrators use as a health command. A non-200 answer or an // unreachable admin server is an error, which main turns into exit status 1. diff --git a/cmd/dbrest/main.go b/cmd/dbrest/main.go index 716c0f4..d97dfda 100644 --- a/cmd/dbrest/main.go +++ b/cmd/dbrest/main.go @@ -66,7 +66,7 @@ func run() error { return err } if showVersion { - fmt.Println("dbrest " + versionString()) + fmt.Println(versionLine()) return nil } if example { diff --git a/cmd/dbrest/reload.go b/cmd/dbrest/reload.go index 09bfee3..789dce5 100644 --- a/cmd/dbrest/reload.go +++ b/cmd/dbrest/reload.go @@ -3,8 +3,9 @@ // same by keeping the HTTP frontend behind an atomic handler and rebuilding // it from the new inputs; an in-flight request keeps the snapshot it started // with. A failed reload logs and keeps serving with the previous state, the -// upstream behavior. The per-driver paths (LISTEN on db-channel, db-config, -// db-pre-config) live with each backend and are not wired here yet. +// upstream behavior. The signal handler is platform-specific and lives in +// reload_signals_unix.go (the Unix signals do not exist on Windows); the +// db-channel listener lives in watchDBChannel below. package main import ( @@ -12,10 +13,8 @@ import ( "log" "net/http" "os" - "os/signal" "sync" "sync/atomic" - "syscall" "time" "github.com/tamnd/dbrest/adminapi" @@ -203,26 +202,3 @@ func (a *app) watchDBChannel(ctx context.Context) { } }() } - -// watchSignals installs the two reload signals. Reload failures log and keep -// the previous state; they never terminate the process. -func (a *app) watchSignals() { - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2) - go func() { - for s := range ch { - switch s { - case syscall.SIGUSR1: - log.Printf("dbrest: received SIGUSR1, reloading the schema cache") - if err := a.reloadSchema(); err != nil { - log.Printf("dbrest: schema cache reload failed, keeping the old cache: %v", err) - } - case syscall.SIGUSR2: - log.Printf("dbrest: received SIGUSR2, reloading the configuration") - if err := a.reloadConfig(os.Environ()); err != nil { - log.Printf("dbrest: config reload failed, keeping the old config: %v", err) - } - } - } - }() -} diff --git a/cmd/dbrest/reload_signals_other.go b/cmd/dbrest/reload_signals_other.go new file mode 100644 index 0000000..2db3dfa --- /dev/null +++ b/cmd/dbrest/reload_signals_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package main + +import "log" + +// watchSignals is a no-op on platforms without the SIGUSR1/SIGUSR2 reload +// signals (Windows). Reloads there are driven by the db-channel listener and +// the admin API; the signal path simply is not available. +func (a *app) watchSignals() { + log.Printf("dbrest: signal-driven reload is unavailable on this platform; use the db-channel or the admin API") +} diff --git a/cmd/dbrest/reload_signals_unix.go b/cmd/dbrest/reload_signals_unix.go new file mode 100644 index 0000000..750add9 --- /dev/null +++ b/cmd/dbrest/reload_signals_unix.go @@ -0,0 +1,35 @@ +//go:build unix + +package main + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +// watchSignals installs the two reload signals. Reload failures log and keep +// the previous state; they never terminate the process. SIGUSR1 and SIGUSR2 are +// Unix-only, so this handler is built only on Unix; see reload_signals_other.go +// for the no-op on the platforms that lack them. +func (a *app) watchSignals() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2) + go func() { + for s := range ch { + switch s { + case syscall.SIGUSR1: + log.Printf("dbrest: received SIGUSR1, reloading the schema cache") + if err := a.reloadSchema(); err != nil { + log.Printf("dbrest: schema cache reload failed, keeping the old cache: %v", err) + } + case syscall.SIGUSR2: + log.Printf("dbrest: received SIGUSR2, reloading the configuration") + if err := a.reloadConfig(os.Environ()); err != nil { + log.Printf("dbrest: config reload failed, keeping the old config: %v", err) + } + } + } + }() +}