diff --git a/Dockerfile.dev b/Dockerfile.dev index 7c8bbcd34..8ba87e7ab 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,21 +18,6 @@ RUN apk add --no-cache \ # Set shell with pipefail for safer pipe operations SHELL ["/bin/ash", "-eo", "pipefail", "-c"] -# Install golang-migrate for database migrations (architecture-aware, checksum-verified) -ARG TARGETARCH -RUN MIGRATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \ - if [ "$MIGRATE_ARCH" = "arm64" ]; then \ - MIGRATE_SHA256="9c95441cc430ffdac89276d14de5e2f18bfafca00796c2895490d62e3776d104"; \ - else \ - MIGRATE_SHA256="26c53c9162c9c4aaa84c47cd12455d4a9ac725befbe82850a5937b5ec1e7b8e6"; \ - fi && \ - curl -Lo migrate.tar.gz "https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-${MIGRATE_ARCH}.tar.gz" && \ - echo "${MIGRATE_SHA256} migrate.tar.gz" | sha256sum -c - && \ - tar xvzf migrate.tar.gz && \ - mv migrate /usr/local/bin/migrate && \ - chmod +x /usr/local/bin/migrate && \ - rm migrate.tar.gz - WORKDIR /app # Copy go mod files @@ -41,6 +26,21 @@ COPY go.mod go.sum ./ # Download dependencies RUN go mod download +# Build golang-migrate from this module, exactly as the builder stage in +# Dockerfile does (see the comment there): as a package of the main module with +# no @version suffix, so the version and its dependency set come from go.mod +# rather than from an upstream release tarball, and with -tags=pgx5 so the +# binary registers the pgx5:// scheme (the one docs/DEVELOPMENT.md uses) and +# does not link lib/pq (issue #1849). Before issue #45 this image downloaded the +# v4.17.0 tarball while every other path ran the go.mod version. +RUN MIGRATE_VERSION="$(go list -m -f '{{.Version}}' github.com/golang-migrate/migrate/v4)" && \ + echo "Building golang-migrate ${MIGRATE_VERSION}" && \ + CGO_ENABLED=0 go build \ + -tags=pgx5 \ + -ldflags="-s -w -X main.Version=${MIGRATE_VERSION}" \ + -o /usr/local/bin/migrate \ + github.com/golang-migrate/migrate/v4/cmd/migrate + # Copy source code COPY . . diff --git a/internal/database/postgres/migrations/migrate_cli_build_test.go b/internal/database/postgres/migrations/migrate_cli_build_test.go new file mode 100644 index 000000000..956ea16b2 --- /dev/null +++ b/internal/database/postgres/migrations/migrate_cli_build_test.go @@ -0,0 +1,107 @@ +package migrations + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// migrateCLIBuildSources lists every file that puts a golang-migrate CLI binary +// on a machine or into an image. Each must build migrateCLIPackage as a package +// of this module with -tags=pgx5, the way Dockerfile documents: no @version +// suffix (which would resolve golang-migrate's own superseded dependency pins) +// and no upstream release tarball (which carries upstream's toolchain and +// dependency set, and the lib/pq-backed postgres driver). Dockerfile.dev was +// left on a v4.17.0 tarball after every other source moved (issue #45), so the +// dev container ran an older migrate, with lib/pq, than production. +var migrateCLIBuildSources = []string{ + "Dockerfile", + "Dockerfile.dev", + "Makefile", + ".github/workflows/ci.yml", + ".github/workflows/database-migration.yml", +} + +var ( + // migrateReleaseDownload matches a download of an upstream migrate release + // artifact, e.g. .../golang-migrate/migrate/releases/download/v4.17.0/... + migrateReleaseDownload = regexp.MustCompile(`golang-migrate/migrate/releases/download/`) + // migrateCLIBuildCmd matches a `go build` or `go install` command. + migrateCLIBuildCmd = regexp.MustCompile(`\bgo\s+(build|install)\b`) + // migrateCLIPgx5Tag matches -tags=pgx5, -tags pgx5 and -tags 'pgx5'. + migrateCLIPgx5Tag = regexp.MustCompile(`-tags[=\s]+['"]?` + migrateCLIBuildTag + `['"]?(\s|$)`) +) + +// TestMigrateCLI_BuiltFromModuleWithPgx5 guards issue #45: every source that +// installs the migrate CLI builds it from this module's go.mod with the pgx5 +// tag, so the dev image, CI, the Makefile and the shipped image all run the +// same migrate version with the same driver. +func TestMigrateCLI_BuiltFromModuleWithPgx5(t *testing.T) { + root := mainModuleRoot(t) + for _, rel := range migrateCLIBuildSources { + t.Run(rel, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join(root, rel)) + require.NoError(t, err) + + cmds := shellCommands(string(content)) + var builds int + for _, cmd := range cmds { + assert.False(t, migrateReleaseDownload.MatchString(cmd), + "%s downloads a prebuilt golang-migrate release; build %s from this module instead: %s", + rel, migrateCLIPackage, cmd) + if !migrateCLIBuildCmd.MatchString(cmd) || !strings.Contains(cmd, migrateCLIPackage) { + continue + } + builds++ + assert.NotContains(t, cmd, migrateCLIPackage+"@", + "%s pins migrate with an @version suffix, which resolves golang-migrate's own go.mod: %s", + rel, cmd) + assert.Regexp(t, migrateCLIPgx5Tag, cmd, + "%s must build migrate with -tags=%s: %s", rel, migrateCLIBuildTag, cmd) + } + assert.NotZero(t, builds, + "%s does not build %s with go build/install; update this guard if it no longer installs migrate", + rel, migrateCLIPackage) + }) + } +} + +// shellCommands returns the non-comment lines of a Dockerfile, Makefile or +// workflow with backslash continuations joined, so a multi-line RUN reads as +// one command. +func shellCommands(text string) []string { + var cmds []string + var cur strings.Builder + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "#") { + continue + } + if cont, ok := strings.CutSuffix(line, `\`); ok { + cur.WriteString(cont) + cur.WriteString(" ") + continue + } + cur.WriteString(line) + if cmd := strings.TrimSpace(cur.String()); cmd != "" { + cmds = append(cmds, cmd) + } + cur.Reset() + } + if cmd := strings.TrimSpace(cur.String()); cmd != "" { + cmds = append(cmds, cmd) + } + return cmds +} + +// TestShellCommands_JoinsContinuationsAndDropsComments pins the parser the +// guard above relies on: a regression here would make it miss a split command. +func TestShellCommands_JoinsContinuationsAndDropsComments(t *testing.T) { + text := "# go install x@v1\nRUN a && \\\n # comment inside\n go build \\\n -tags=pgx5 pkg\nCMD b\n" + assert.Equal(t, []string{"RUN a && go build -tags=pgx5 pkg", "CMD b"}, shellCommands(text)) +} diff --git a/internal/database/postgres/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index 0f5f1015a..e802c06da 100644 --- a/internal/database/postgres/migrations/migrate_security_test.go +++ b/internal/database/postgres/migrations/migrate_security_test.go @@ -207,8 +207,9 @@ const ( // // Known limit, stated so nobody reads more into it: the CLI subtest pins the // build as configured, with migrateCLIBuildTag. It asserts that build stays -// lib/pq-free; it cannot notice someone changing the tag back to `postgres` in -// the Dockerfile, which is a build-configuration change no Go test observes. +// lib/pq-free; it does not notice someone changing the tag back to `postgres` in +// the Dockerfile. TestMigrateCLI_BuiltFromModuleWithPgx5 covers that by reading +// each file that builds the CLI. func TestLibPqDriverNotRegistered(t *testing.T) { t.Run("driver not registered", func(t *testing.T) { for _, scheme := range []string{"postgres", "postgresql"} {