Add CORS credentials, transactional endpoints, and container deployment
CORS
cors.go never set Access-Control-Allow-Credentials, so the
cookie-authenticated API was unreadable from any cross-origin frontend:
the server answered correctly and the browser blocked the page from
reading it. Set for allowlisted origins on both the preflight and the
actual response. Three tests added.
HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
half of what a cross-origin browser call needs; SameSite is judged on
registrable domain, so a frontend on an unrelated domain gets perfect CORS
headers and still no cookie. "none" is the only value that survives that,
and validate() refuses it without the Secure flag.
The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
together with credentials, so it would break every authenticated call
rather than loosen anything.
Transactional endpoints (api-contract.md 12.1)
POST /api/v1/job-applications/{id}/hire
POST /api/v1/job-postings/{id}/assignments
Replaces two client-side loops that wrote several records with no
transaction and no rollback. Each is now one endpoint and one transaction,
built over repo.Repo so org scoping, derived columns, type casts and error
translation are not re-derived. Authorization reuses the existing policy
table rather than adding a parallel one: a workflow is exactly as
privileged as the writes it performs. 13 tests, including both rollback
paths.
Bug fix in the repository layer
repo.bindValue handled int64/int/float64/string but not int32, which is
what pgx returns for a PostgreSQL `int` column. Nothing previously read a
record and wrote one of its fields elsewhere, so it never surfaced; the
hire flow does exactly that and failed with "ai_score must be a number".
Both KindInt and KindFloat now accept the widths pgx actually produces.
Deployment
infrastructure/Dockerfile.api multi-stage, cross-compiling (BUILDPLATFORM
+ GOARCH) so linux/amd64 builds from arm64 are compiled rather than
emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
setpassword and migrate, plus the migrations, so a Kubernetes
initContainer can apply the schema from the same image and tag as the
API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
is not pulled from rotation during a migration window.
infrastructure/docker-compose.yml migrations run to completion before the
API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
with TLS enabled so APP_ENV=production is met rather than dodged.
scripts/drop_public_tables.go the one-off used to clear an unrelated
schema from krowdb on 2026-08-24, kept for the record. Build-tagged
ignore and gated on CONFIRM_DROP=yes.
Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
This commit is contained in:
155
infrastructure/Dockerfile.api
Normal file
155
infrastructure/Dockerfile.api
Normal file
@@ -0,0 +1,155 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# ============================================================================
|
||||
# Krow API — production image
|
||||
#
|
||||
# Build from the REPOSITORY ROOT, not from this directory: the build needs
|
||||
# go-api/ and seed/, and a Dockerfile can never reach above its context.
|
||||
#
|
||||
# docker build -f infrastructure/Dockerfile.api -t krow-api:latest .
|
||||
#
|
||||
# Three binaries ship in the image, because all three are things an operator
|
||||
# needs against a running deployment and none of them justify a second image:
|
||||
#
|
||||
# /usr/local/bin/api the HTTP service (the default command)
|
||||
# /usr/local/bin/seed loads the demo fixture
|
||||
# /usr/local/bin/setpassword sets a user's password — without it a fresh
|
||||
# database has no one who can sign in
|
||||
#
|
||||
# Migrations are deliberately NOT run by this image. They are a discrete deploy
|
||||
# step against the target database BEFORE the new binary rolls out, which is
|
||||
# what makes expand/migrate/contract possible: see README.md "Staging and
|
||||
# production". docker-compose.yml runs them as their own one-shot service.
|
||||
# ============================================================================
|
||||
|
||||
# ── Build ───────────────────────────────────────────────────────────────────
|
||||
# --platform=$BUILDPLATFORM pins this stage to the MACHINE DOING THE BUILDING,
|
||||
# not the machine that will run the image. Combined with GOARCH below, that
|
||||
# turns a cross-platform build into cross-COMPILATION rather than emulation.
|
||||
#
|
||||
# It matters a lot in practice: building linux/amd64 from an Apple Silicon Mac
|
||||
# without this runs the entire Go toolchain under QEMU, which is roughly an
|
||||
# order of magnitude slower. Every binary here is CGO_ENABLED=0 and therefore
|
||||
# pure Go, so the Go compiler can target another architecture directly and there
|
||||
# is nothing emulation would buy.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
|
||||
|
||||
# Supplied automatically by buildx from --platform. Declared here so the build
|
||||
# stage can read them; TARGETARCH is "amd64", "arm64", etc.
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Dependencies first, in their own layer. go.mod and go.sum change far less
|
||||
# often than source, so an edit to a handler does not re-download the module
|
||||
# graph.
|
||||
COPY go-api/go.mod go-api/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY go-api/ ./
|
||||
|
||||
# CGO_ENABLED=0 produces a static binary, which is what lets the final stage be
|
||||
# a near-empty image with no libc to keep patched.
|
||||
# -trimpath keeps build-machine paths out of panics and binaries
|
||||
# -s -w drops the symbol table and DWARF; roughly a third off the size
|
||||
ARG VERSION=dev
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/api ./cmd/api && \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" -o /out/setpassword ./cmd/setpassword
|
||||
|
||||
# The golang-migrate CLI, built here rather than pulled as a second image.
|
||||
#
|
||||
# `go install pkg@version` resolves in its own module context, so this does NOT
|
||||
# touch go.mod or go.sum. Having it in the image means the Kubernetes manifests
|
||||
# can run migrations from an initContainer using the SAME image and tag as the
|
||||
# API — so the schema and the binary that expects it can never be different
|
||||
# versions, which is the failure a separate migrate image invites.
|
||||
ARG MIGRATE_VERSION=v4.19.0
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install -tags 'postgres' \
|
||||
github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION} && \
|
||||
cp "$(go env GOPATH)/bin/${TARGETOS}_${TARGETARCH}/migrate" /out/migrate 2>/dev/null || \
|
||||
cp "$(go env GOPATH)/bin/migrate" /out/migrate
|
||||
|
||||
# Fail the build rather than the deploy if the tests do not pass. Skipped by
|
||||
# default because the database-backed tests need PostgreSQL, which a build
|
||||
# container does not have; turn it on in CI where a service container does.
|
||||
# Note: this runs on the BUILD architecture, so it is only meaningful when
|
||||
# building natively. Under cross-compilation the test binaries would target the
|
||||
# other architecture and could not execute here.
|
||||
ARG RUN_TESTS=false
|
||||
RUN if [ "$RUN_TESTS" = "true" ]; then go test ./... ; fi
|
||||
|
||||
|
||||
# ── Runtime ─────────────────────────────────────────────────────────────────
|
||||
# Alpine rather than distroless, for one concrete reason: HEALTHCHECK needs a
|
||||
# command inside the container, and distroless has no shell and no wget. The
|
||||
# trade is ~8 MB and an apk surface to keep updated, in exchange for the
|
||||
# orchestrator being able to see whether this instance is actually serving.
|
||||
FROM alpine:3.20 AS runtime
|
||||
|
||||
# ca-certificates: the pgx driver needs a trust store to verify a managed
|
||||
# database's TLS certificate under sslmode=verify-full.
|
||||
# tzdata: timestamps are timestamptz and the seeder anchors shift dates to now.
|
||||
RUN apk add --no-cache ca-certificates tzdata wget && \
|
||||
adduser -D -H -u 10001 -s /sbin/nologin krow
|
||||
|
||||
COPY --from=build /out/api /usr/local/bin/api
|
||||
COPY --from=build /out/seed /usr/local/bin/seed
|
||||
COPY --from=build /out/setpassword /usr/local/bin/setpassword
|
||||
COPY --from=build /out/migrate /usr/local/bin/migrate
|
||||
|
||||
# The seed fixture, so `seed` works without a bind mount.
|
||||
COPY seed/fixtures/seed.json /app/seed/fixtures/seed.json
|
||||
ENV SEED_FIXTURE_PATH=/app/seed/fixtures/seed.json
|
||||
|
||||
# The migration files, so an initContainer can apply them from this image.
|
||||
# They are read-only at runtime and the process is non-root, so nothing here
|
||||
# can be rewritten by the service.
|
||||
COPY migrations/ /app/migrations/
|
||||
ENV MIGRATIONS_DIR=/app/migrations
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Non-root. The service binds 8080, which needs no privilege, so there is no
|
||||
# reason for this process to be able to write to its own filesystem or read
|
||||
# another container's mounts.
|
||||
USER krow:krow
|
||||
|
||||
# 0.0.0.0, NOT the 127.0.0.1 that config.Load defaults to. A container that
|
||||
# binds loopback is reachable only from inside itself — the symptom is a
|
||||
# published port that refuses every connection while the process looks healthy.
|
||||
ENV HTTP_HOST=0.0.0.0 \
|
||||
HTTP_PORT=8080 \
|
||||
APP_ENV=production \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# The check keys on the STATUS CODE, not on the body, because the two say
|
||||
# different things on purpose (httpserver.handleHealth):
|
||||
#
|
||||
# 200 "ok" serving normally
|
||||
# 200 "degraded" process healthy, schema unmigrated or dirty — still 200,
|
||||
# because the fault is the database's and pulling this
|
||||
# instance would not fix it
|
||||
# 503 "unavailable" database unreachable
|
||||
#
|
||||
# Grepping the body for "ok" would mark a degraded instance unhealthy and take
|
||||
# the whole deployment out of rotation during a migration window, which is
|
||||
# exactly the behaviour that 200 was chosen to avoid. wget exits non-zero on
|
||||
# 503 and zero on either 200, which is the intended semantics.
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1
|
||||
|
||||
# Exec form: the binary becomes PID 1 and receives SIGTERM directly, which is
|
||||
# what cmd/api's signal.NotifyContext is waiting for to drain connections.
|
||||
ENTRYPOINT ["/usr/local/bin/api"]
|
||||
Reference in New Issue
Block a user