diff --git a/.dockerignore b/.dockerignore index b84d7f9..fe1482a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,9 @@ **/.build **/.swiftpm +# Test sources +**/Tests + # Xcode project (not used by the Linux build) *.xcodeproj diff --git a/Services/Website/.env.local b/Services/Website/.env.local index 3835e3e..ff393c8 100644 --- a/Services/Website/.env.local +++ b/Services/Website/.env.local @@ -20,8 +20,11 @@ HOST_USER= # Name of the Docker image to pull/run. IMAGE_NAME=website -# Platform of the Docker image to pull/run. -IMAGE_PLATFORM=linux/arm64 +# Platform of the deployment image +IMAGE_PLATFORM=linux/amd64 + +# Platform of the *local* build only (docker-compose.override.yml) +BUILD_PLATFORM=linux/arm64 # Tag of the image to pull/run. # Use a semver in production; avoid `latest` so rollbacks are unambiguous. diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index c1c6879..f1230c6 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -1,7 +1,7 @@ # ================================ # Asset image # ================================ -FROM node:22-alpine AS assets +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS assets ARG ESBUILD_VERSION=0.28.1 ARG OXIPNG_VERSION=9.1.5 @@ -30,7 +30,7 @@ COPY --from=assets /static / # ================================ # Build image # ================================ -FROM swift:6.3-noble AS build +FROM swift:6.3-noble@sha256:69bf1f0281e13d82c9e49d67c2dd1dcc8c00bad738c860f4323d7078787ec8ea AS build # Install OS updates RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ @@ -59,10 +59,15 @@ COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources COPY ./Packages/Utility/Sources ./Packages/Utility/Sources COPY ./Services/Website/Sources ./Services/Website/Sources -COPY ./Services/Website/Tests ./Services/Website/Tests -# Build the application, with optimizations, with static linking, and using jemalloc -RUN swift build --package-path ./Services/Website -c release \ +# Build the application, with optimizations, with static linking, and using jemalloc. +# +# SPM validates the path of every target in the root package — including the test targets — even when only the +# executable product is built, so those directories have to exist. They are created empty instead of copied: the test +# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer, +# and it keeps the local database bind mount under Tests/DB out of the image entirely. +RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \ + && swift build --package-path ./Services/Website -c release \ --product "Website" \ --static-swift-stdlib \ -Xlinker -ljemalloc @@ -89,7 +94,7 @@ RUN chmod -R a-w ./Resources # ================================ # Run image # ================================ -FROM ubuntu:noble +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea # Make sure all system packages are up to date, and install only essential packages. RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ diff --git a/Services/Website/Makefile b/Services/Website/Makefile index 7caf706..a5b63fd 100644 --- a/Services/Website/Makefile +++ b/Services/Website/Makefile @@ -126,7 +126,7 @@ ast-minify: ## Preview the minified JS/CSS assets in .build/minified .PHONY: img-check img-check: ## Check the production image builds @docker build \ - --platform linux/amd64 \ + --platform $(IMAGE_PLATFORM) \ --file Dockerfile \ ../.. diff --git a/Services/Website/README.md b/Services/Website/README.md index c8c3314..5355f6f 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -246,7 +246,20 @@ cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test # again `POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `site` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`. ## Deployment -The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`. `make img-check` pins the build to `linux/amd64`; `make img-release` builds for `IMAGE_PLATFORM`. +The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`. + +`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment. + +The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason). + +### Base images +All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with + +```sh +docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}' +``` + +Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself. The executable is the `ENTRYPOINT` and its serving flags are the `CMD`: ```dockerfile @@ -256,7 +269,7 @@ CMD ["--http-host", "0.0.0.0", "--http-port", "8080"] The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint. ```sh -make img-check # verify it builds for linux/amd64, without tagging or publishing +make img-check # verify it builds for IMAGE_PLATFORM, without tagging or publishing make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required) ``` @@ -269,7 +282,7 @@ docker compose -f docker-compose.yml up -d ### Static assets `Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from. -The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit: +The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit: - CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`). - PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller. - SVGs are minified with [svgo](https://github.com/svg/svgo), recursively. @@ -321,7 +334,8 @@ The Makefile and Compose files read these from `.env` (or the environment). Prov | `HOST_OWNER` | Registry namespace / owner. | | `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. | | `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. `IMAGE_TAG` is the fallback for `make img-release`'s `version=` argument. | -| `IMAGE_PLATFORM` | Build platform for `make img-release` (e.g. `linux/amd64`). `make img-check` pins `linux/amd64` regardless. | +| `IMAGE_PLATFORM` | Deployment architecture (default `linux/amd64`). Drives `make img-check`, `make img-release`, and the `platform:` the production Compose file runs the pulled image with — keep it matched to the deployment host. | +| `BUILD_PLATFORM` | Architecture of the *local* development build only (default `linux/arm64`); set it to `linux/amd64` on an Intel Mac or an amd64 Linux box. Never used by the production Compose file. | | `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). | | `LOG_LEVEL` | Runtime log level (default `info`). | | `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). | diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index 0ece36c..80389f0 100644 --- a/Services/Website/docker-compose.override.yml +++ b/Services/Website/docker-compose.override.yml @@ -10,7 +10,9 @@ services: website: image: ${IMAGE_NAME}:${IMAGE_TAG:-latest} - platform: linux/arm64 + # The local build targets the development machine, not the deployment: this cancels the base file's + # ${IMAGE_PLATFORM}. Set BUILD_PLATFORM=linux/amd64 in .env when developing on an Intel Mac or an amd64 Linux box. + platform: ${BUILD_PLATFORM:-linux/arm64} build: context: ../.. dockerfile: Services/Website/Dockerfile diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index f9a965d..d7de7b1 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -11,7 +11,7 @@ name: site-platform services: website: image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest} - platform: linux/amd64 + platform: ${IMAGE_PLATFORM:-linux/amd64} container_name: ${HOST_OWNER}-${IMAGE_NAME} restart: unless-stopped ports: