Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
# To refresh: `docker buildx imagetools inspect golang:1.26.9-alpine3.24`
# (or use the Docker Hub API tags endpoint) and update the digest below.
# A Renovate / Dependabot config can automate this if desired.
# Keep this digest in sync with the builder stage in Dockerfile.
# Keep this digest in sync with the builder stage in Dockerfile; the
# golang-migrate binary below is built on it, and
# internal/database/postgres/migrations/dockerfile_migrate_test.go fails when
# the two drift.
FROM golang:1.26.9-alpine3.24@sha256:cdfd4fe2da6b225d8b40c6b7a105736e548e83ff56d5d8f9394446eeb5eb84e0 AS development

# Install development tools and Air for hot reload
Expand All @@ -18,21 +21,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
Expand All @@ -41,6 +29,24 @@ COPY go.mod go.sum ./
# Download dependencies
RUN go mod download

# Build golang-migrate from source on this image's pinned Go toolchain, exactly
# as the builder stage in Dockerfile does; see the comment there for why (a
# prebuilt release tarball carries upstream's toolchain and dependency pins,
# and -tags=pgx5 keeps lib/pq out of the binary). The version comes from
# go.mod. The RUN instruction is kept identical to Dockerfile's, so the dev
# container runs the same migrate binary as production, and
# internal/database/postgres/migrations/dockerfile_migrate_test.go fails if
# the two diverge. Issue #203.
ARG TARGETARCH
ARG TARGETOS=linux
RUN MIGRATE_VERSION="$(go list -m -f '{{.Version}}' github.com/golang-migrate/migrate/v4)" && \
echo "Building golang-migrate ${MIGRATE_VERSION} for ${TARGETOS}/${TARGETARCH}" && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} 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 . .

Expand Down
132 changes: 132 additions & 0 deletions internal/database/postgres/migrations/dockerfile_migrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package migrations

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// prebuiltMigrateSources are the places a prebuilt golang-migrate binary comes
// from: upstream's release tarballs and its Docker Hub image (via FROM or
// COPY --from). A prebuilt binary carries whatever Go toolchain and dependency
// pins upstream built it with, so no base-image or go.mod bump in this repo
// reaches it (issues #1833 and #203).
var prebuiltMigrateSources = []string{
"golang-migrate/migrate/releases",
"migrate/migrate:",
"migrate/migrate@",
}

// TestDockerfilesBuildMigrateIdentically pins the development image's
// golang-migrate binary to the production one. Both Dockerfiles must build it
// from source with one identical RUN instruction, in a stage based on the same
// digest-pinned Go image, and neither may download a prebuilt release.
//
// The two images are separate Dockerfiles, so they cannot share a build stage;
// this test is what stops them drifting apart again. Dockerfile.dev downloaded
// the v4.17.0 release tarball long after Dockerfile switched to building
// v4.19.1 from this module (issue #203).
func TestDockerfilesBuildMigrateIdentically(t *testing.T) {
root := mainModuleRoot(t)
prod := parseDockerfile(t, filepath.Join(root, "Dockerfile"))
dev := parseDockerfile(t, filepath.Join(root, "Dockerfile.dev"))

for name, instrs := range map[string][]string{"Dockerfile": prod, "Dockerfile.dev": dev} {
for _, instr := range instrs {
for _, src := range prebuiltMigrateSources {
assert.NotContains(t, instr, src,
"%s pulls in a prebuilt golang-migrate binary; build it from this module "+
"instead, as the RUN instruction in Dockerfile does", name)
}
}
}

prodRun, prodBase := migrateBuildInstruction(t, "Dockerfile", prod)
devRun, devBase := migrateBuildInstruction(t, "Dockerfile.dev", dev)

assert.Contains(t, prodRun, " go build ",
"Dockerfile must build migrate with `go build` as a package of this module")
assert.NotContains(t, prodRun, migrateCLIPackage+"@",
"an @version suffix resolves golang-migrate's own go.mod instead of this module's")
assert.Contains(t, prodRun, "-tags="+migrateCLIBuildTag,
"Dockerfile must build migrate with -tags=%s so lib/pq is not linked", migrateCLIBuildTag)

assert.Equal(t, prodRun, devRun,
"Dockerfile.dev must build golang-migrate with exactly the RUN instruction Dockerfile uses")
assert.Contains(t, prodBase, "@sha256:", "Dockerfile's migrate build stage must be digest-pinned")
assert.Equal(t, prodBase, devBase,
"Dockerfile.dev must build golang-migrate on the same pinned Go image as Dockerfile")
}

// migrateBuildInstruction returns the single RUN instruction in instrs that
// builds the migrate CLI, and the base image of the stage it runs in.
func migrateBuildInstruction(t *testing.T, name string, instrs []string) (run, base string) {
t.Helper()

var runs []string
stageBase := ""
for _, instr := range instrs {
keyword, args, _ := strings.Cut(instr, " ")
switch strings.ToUpper(keyword) {
case "FROM":
stageBase = stageBaseImage(args)
case "RUN":
if strings.Contains(args, migrateCLIPackage) {
runs = append(runs, instr)
run, base = instr, stageBase
}
}
}

require.Lenf(t, runs, 1, "%s must contain exactly one RUN instruction that builds %s, found %d: %q",
name, migrateCLIPackage, len(runs), runs)
return run, base
}

// stageBaseImage returns the image reference of a FROM instruction's
// arguments, skipping flags such as --platform and dropping any `AS name`.
func stageBaseImage(args string) string {
for _, field := range strings.Fields(args) {
if !strings.HasPrefix(field, "--") {
return field
}
}
return ""
}

// parseDockerfile returns the file's instructions with line continuations
// joined, comment and blank lines dropped (Docker strips both, including inside
// a continued instruction), and runs of whitespace collapsed to one space so
// that indentation differences do not count as drift. Neither Dockerfile sets
// an escape directive, so the continuation character is the default backslash.
func parseDockerfile(t *testing.T, path string) []string {
t.Helper()

data, err := os.ReadFile(path)
require.NoErrorf(t, err, "reading %s", path)

var instrs []string
var cur strings.Builder
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
body, continued := strings.CutSuffix(trimmed, `\`)
cur.WriteString(body)
cur.WriteString(" ")
if continued {
continue
}
instrs = append(instrs, strings.Join(strings.Fields(cur.String()), " "))
cur.Reset()
}
require.Emptyf(t, strings.TrimSpace(cur.String()), "%s ends inside a continued instruction", path)
require.NotEmptyf(t, instrs, "%s has no instructions", path)

return instrs
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ 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.
// the Dockerfile. TestDockerfilesBuildMigrateIdentically covers that side, by
// reading the RUN instruction the Dockerfiles actually build migrate with.
func TestLibPqDriverNotRegistered(t *testing.T) {
t.Run("driver not registered", func(t *testing.T) {
for _, scheme := range []string{"postgres", "postgresql"} {
Expand Down
Loading