feat: complete dockerization (optional)

This commit is contained in:
fsylvain committed 2026-10-05 04:52:49 +02:00
1 parent 3e01c29657
commit d275ba3c14
38 files changed
+1244 -20

No files matched your search

@@ -0,0 +1,87 @@
Feature: Container-aware update guidance
As the owner of a RenewCMS site running in Docker
I want Settings to say "update by pulling a new image" instead of "Not prepared"
So that I never see a browser-update flow that cannot work in a container
Background:
Given browser updates need Linux with systemd and the helper that "renewcms enable-browser-updates" configures
And a binary replaced inside a container is lost when the container is recreated,
so in a container the update is a new image: "docker compose pull && docker compose up -d"
And config.Settings gains Container bool, read from the env var RENEWCMS_CONTAINER (default "false", set to "true" by the Dockerfile and docker-compose.yml)
And RENEWCMS_CONTAINER is added to envDefaults in cmd/server/main.go, to .env.example and to docs/configuration.md; it is not in requiredEnvVars
And only "true" (case-insensitive) enables container mode; any other value means false
And the update status use case receives the bool through its constructor from api/dependecyInjection.go; internal/domain/update stays stdlib only
And the update check, update-status.json and the release feed work unchanged in container mode
And GET /v1/system/update gains a 13th key, container (bool), after started_at; the other 12 keys are unchanged
And in container mode can_apply is false for everyone, even if update-helper.json exists in the data directory
And POST /v1/system/update/download and /restart keep their guards and answer 409 because can_apply is false (existing behaviour, not re-specified)
And errors stay plain text from http.Error
And the user approved the copy below and the 13th key on 2026-10-05; docs/design is read-only for agents, so the design files are updated by the user, and the visual gate for container states stays PENDING until a board frame exists
Scenario Outline: The status carries the container flag
Given RENEWCMS_CONTAINER is "<value>"
When a verified user sends GET /v1/system/update
Then the JSON key container is <flag>
Examples:
| value | flag |
| true | true |
| TRUE | true |
| false | false |
| | false |
| yes | false |
Scenario: Container mode disables apply even with a helper
Given RENEWCMS_CONTAINER is "true" and HelperDetector reports the helper installed
When the owner sends GET /v1/system/update
Then can_apply is false and container is true
And a newer stored result still reports available true, latest and notes
Scenario: The check still works in a container
Given container mode and a feed that reports "v2.9.0-beta" newer than the running "v2.8.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is true and the result is stored in update-status.json
Scenario Outline: Download and restart are refused in a container
Given container mode and a stored newer release
When the owner sends POST <path>
Then the status is 409 and no job starts and the fetcher and the restart request writer are not called
Examples:
| path |
| /v1/system/update/download |
| /v1/system/update/restart |
Scenario: Host installs are unchanged
Given RENEWCMS_CONTAINER is unset
Then container is false and can_apply follows the helper as before
And "Not prepared" still shows its "renewcms enable-browser-updates" command
Scenario: Settings, owner, container, update available (route /settings)
Given GET /v1/system/update resolves available true, container true, can_apply false, latest "v2.9.0-beta"
Then the block uses the "Not prepared" frame's layout: icon info, tone info
And the title reads "Version 2.9.0-beta is available"
And the text reads "You have v2.8.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server."
And a server-command block reads "docker compose pull && docker compose up -d" with the Copy button ("Copied" with a check for 2 s, announced)
And a link "How to update" with arrow-up-right points to docs/docker.md
And no Update, Restart or "Check for updates" button is rendered
And the "renewcms enable-browser-updates" command and its note are not rendered
Scenario: Settings, owner, container, up to date
Given container true and available false
Then "You're up to date" shows as in the existing Up to date state, the Restart button is not rendered and the container hint is not rendered
And "Check for updates" is still offered
Scenario: Settings, admin, container
Given the session role is "admin", container true and available true
Then the existing Admin view shows unchanged ("Only the owner can update RenewCMS.") with no command block
Scenario: Settings, non-container, no helper
Given container false and can_apply false
Then the existing "Not prepared" state shows unchanged
Scenario: The shipped Docker setup turns container mode on
Given the Dockerfile and docker-compose.yml
Then both set RENEWCMS_CONTAINER=true
And docs/docker.md and docs/configuration.md describe the variable
+25
View File
@@ -0,0 +1,25 @@
# QA: Container update guidance
## Automated (Go, test/, httptest)
1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...).
2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
3. GET status: container key present and equal to the table; the other 12 keys are unchanged.
4. Container true with a helper present: can_apply false for the owner.
5. POST check in container mode: 200, available true, stored.
6. POST download and restart in container mode: 409, no fake called.
7. Container false: the existing updates_* tests still pass.
8. An admin receives can_apply false and container true.
## SPA (Vitest, mocked /v1)
- Container + available: the block, command, Copy, "How to update", and no buttons. Reuse the "Not prepared" sample data from the board.
- Container + up to date: no Restart.
- Admin: the existing admin view.
- Non-container "Not prepared": unchanged.
- Visual: container states are PENDING until a board frame exists; report as PENDING, never as passing. Existing states must still pass `task visual:check -- settings`.
## Manual (real Docker, ./docker-compose.yml)
1. `docker compose up -d --build`; confirm RENEWCMS_CONTAINER=true with `docker compose exec renewcms env`.
2. Point UPDATE_FEED_URL at a feed with a newer release; log in as owner; /settings shows the container block, no Update or Restart.
3. The Copy button puts the exact command on the clipboard.
4. Run the shown command on a host that has a newer image; Settings shows the new version afterwards.
5. Run the binary on the host without the variable: "Not prepared" shows the old command.
+45
View File
@@ -0,0 +1,45 @@
Feature: Docker deployment
RenewCMS can run in a container built from the repository. The container
reuses the existing configuration contract in cmd/server/main.go
(requiredEnvVars, envDefaults); no new variables are introduced.
Background:
Given the image is built with the default build, without the nofrontend tag
And the container runs the binary directly as PID 1 under a non-root user
And the environment sets DB_FILE and UPLOAD_DIR to paths on a named volume
And the required variables PUBLIC_URL, JWT_SECRET, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT come from an env file at runtime
Scenario: Healthy start
When the container starts with every required variable set
Then it logs "RenewCMS <version> (production) listening on :8080"
And GET /healthz on the published port returns 200 with the body "ok"
Scenario: Frontend and API are both served
When the container is running
Then GET / returns 200 with the SPA index.html
And GET /v1/auth/registration returns 200 with a JSON body
Scenario Outline: A missing required variable stops the container
When the container starts without <variable>
Then the process exits with a non-zero status
And the log contains "Environment variable <variable> is not set"
Examples:
| variable |
| PUBLIC_URL |
| JWT_SECRET |
| SMTP_LOGIN |
| SMTP_SENDER |
| SMTP_PASSWORD |
| SMTP_HOST |
| SMTP_PORT |
Scenario: Data survives container recreation
Given an account was registered while the container was running
When the container is removed and recreated on the same named volume
Then the same account can log in
Scenario: Graceful shutdown on SIGTERM
When the container receives SIGTERM
Then the log contains "shutting down..."
And the container exits with status 0 within 10 seconds
+98
View File
@@ -0,0 +1,98 @@
# QA: Docker deployment
Packaging and docs only. There is no Go test in test/ and no Vitest spec for this slice. All steps are manual command steps run from a clean checkout on a machine with Docker and Compose v2. Record results in the Notes table. Anything not run is reported NOT RUN, never PASS.
## Preconditions
- Clean checkout, no bin/, tmp/, node_modules or internal/infrastructure/assets/dist present.
- docker.env created from docker.env.example. JWT_SECRET is generated (openssl rand -hex 32) and SMTP values are real or placeholders. Nothing is committed.
- For local HTTP trial, ENVIRONMENT=development (otherwise the Secure cookie is not stored over HTTP).
## Static checks (no Docker daemon needed)
1. Dockerfile, .dockerignore and docker-compose.yml contain no comment lines (no line starting with `#`, no trailing ` #`).
2. .dockerignore lists tmp/, bin/, node_modules (also web/node_modules), .git, internal/infrastructure/assets/dist, .env and docker.env.
3. Dockerfile: three stages (bun, go, final). The Go build sets CGO_ENABLED=0, has no `-tags nofrontend`, and uses `-ldflags` containing `-X main.version=` from a build arg. The final stage has USER non-root, exec-form ENTRYPOINT (no `sh -c`), and no ENV, ARG or COPY holding JWT_SECRET or any SMTP_* value.
4. docker-compose.yml: one service, a named volume mounted at the data directory, a `ports` entry, `env_file`, a healthcheck on /healthz, `stop_grace_period` >= 15s.
5. git diff --stat shows no change under api/, internal/, web/src/, cmd/ and test/. Only Dockerfile, .dockerignore, docker-compose.yml, docker.env.example, Taskfile.yml, .gitignore, README.md and docs/ changed. No new env var appears in cmd/server/main.go.
6. docs/docker.md exists and is linked from README.md and docs/deployment.md.
7. `task --list` shows docker:build, docker:run, docker:up, docker:down and docker:logs.
## Build
8. `task docker:build` succeeds from the clean checkout, with no pre-built SPA.
9. `docker run --rm <image> --version` prints the version, which is not `dev`.
10. `docker image ls` size recorded. Flag it if it is above roughly 40 MB.
11. `docker image inspect` shows a non-root User, and `docker run --rm --entrypoint id <image>` shows uid not 0.
12. `docker history --no-trunc <image>` contains none of the values in docker.env.
## Run
13. `task docker:up` starts the service. `docker compose ps` shows it healthy within the healthcheck start period.
14. `curl -i http://localhost:8080/healthz` returns 200 and body `ok`.
15. `curl -i http://localhost:8080/` returns 200 text/html, and the SPA loads in a browser.
16. `curl -i http://localhost:8080/v1/auth/registration` returns 200 with JSON.
17. Logs show the listening line, and no permission error creating DB_FILE or UPLOAD_DIR on the volume.
18. Register an account in the SPA, then upload an image, to confirm both the DB and UPLOAD_DIR are writable by the container user.
## Persistence
19. `docker compose down` (no -v), then `task docker:up`. The account from step 18 can log in and the image is still served.
20. `docker compose down -v` removes the volume, and the next start gives an empty instance. Documented in docs/docker.md.
21. The backup procedure in docs/docker.md is followed once and restores into a fresh volume.
## Failure and shutdown
22. For each required variable in turn, remove it from the env file and run `docker compose up`. The container exits non-zero and the log contains `Environment variable <NAME> is not set`.
23. `docker compose stop` (timed) completes in under 10 s, the log contains `shutting down...`, and the exit code is 0. This proves PID 1 receives SIGTERM.
24. Break the healthcheck (stop the app inside the container, or point it at the wrong port): the service becomes unhealthy.
## SMTP (manual, needs real SMTP)
25. With an external SMTP host, trigger a registration or password-reset email and confirm delivery.
26. With a host MTA via host.docker.internal, repeat. On Linux this needs the extra_hosts mapping documented.
27. Confirm the failure mode is documented when SMTP is unreachable.
## Reverse proxy and TLS (manual)
28. Put Caddy or nginx in front, per docs/docker.md, with ENVIRONMENT=production over HTTPS. Log in and confirm the Secure cookie is accepted.
29. Set TRUSTED_PROXIES to the proxy address as seen by the container and confirm the login rate limiter uses X-Real-IP.
## Upgrade
30. Follow docs/docker.md: back up, rebuild with `task docker:build`, `task docker:up`. Data is intact and `--version` shows the new version.
31. Confirm docs state that browser-update Restart is unsupported in Docker (Settings shows "Not prepared").
## Notes to record
| Item | Value |
| --- | --- |
| Image size | |
| Base images and tags used | |
| Container uid | |
| Build time (cold) | |
| Docker and Compose versions | |
| Steps 25-29 run? | |
Not run in this slice: CI, registry publishing, Kubernetes or Helm, multi-arch. SPA tests and visual checks are out of scope.
## Results
Run on Windows 11, Docker 29.8.1, Compose 5.5.1, with a throwaway docker.env (fake secrets, ENVIRONMENT=development) that was deleted afterwards.
| Steps | Result |
| --- | --- |
| 1-7, 31 | PASS |
| 8 | PASS after a fix: `bun run build` failed in the image because vue-tsc cannot resolve .vue modules under the Bun-only node fallback; the Dockerfile now runs `bun run build-only`. vue-tsc passes on the host and under real node 22 |
| 9, 11, 12 | PASS (version v2.8.0-beta-dirty, uid 10001, entrypoint exec form, no secret names in history) |
| 10 | 54.5 MB, above the 40 MB flag (binary 29.9 MB on alpine 3.22) |
| 13-17 | PASS (healthy, /healthz 200 ok, / 200 text/html, /v1/auth/registration 200 JSON, no permission errors) |
| 18-19 | PASS (register, verify, login, image upload to /data/uploads as uid 10001; all survive `down` and recreate; uploaded image still served) |
| 20 | PASS (`down -v` removed the volume) |
| 21 | PASS (tar backup, `down -v`, restore, login and media list intact) |
| 22 | PASS for all 7 vars (exit 2, `Environment variable X is not set`) |
| 23 | PASS (stop 0.4 s, exit 0, `shutting down...` logged) |
| 24 | PASS (wrong-port healthcheck reports unhealthy) |
| 25 | PARTIAL: mail delivered over STARTTLS to a Mailpit container on the compose network with the CA trusted via SSL_CERT_FILE; no real external host |
| 26-29 | NOT RUN (host MTA, Caddy/nginx TLS, TRUSTED_PROXIES) |
| 30 | NOT RUN as a before/after upgrade |
| `task docker:run` | PASS (healthz 200, graceful stop, separate volume renewcms-data) |
Notes
| Item | Value |
| --- | --- |
| Image size | 54.5 MB |
| Base images and tags used | oven/bun:1-alpine, golang:1.27.1-alpine, alpine:3.22 |
| Container uid | 10001 |
| Build time (cold) | not measured, layers were partly cached; about 18 s warm |
| Docker and Compose versions | 29.8.1, 5.5.1 |
| Steps 25-29 run? | 25 partial, 26-29 no |
+71
View File
@@ -0,0 +1,71 @@
Feature: Publish the RenewCMS image to a registry so an upgrade is "docker compose pull"
As a maintainer
I want one task that builds and pushes a versioned multi-arch image
So that site operators upgrade with "docker compose pull && docker compose up -d" and never build from source
Background:
Given there is no CI pipeline on purpose; publishing is a maintainer-run task, like "task release"
And the registry is the Gitea registry of git.floriansylvain.fr and REGISTRY_IMAGE defaults to "git.floriansylvain.fr/fsylvain/renewcms"
And the Dockerfile and docker-compose.yml from docker-deployment exist and are unchanged except where stated here
And Taskfile.yml gains "docker:publish", with variables REGISTRY_IMAGE and VERSION (default: git describe --tags)
And "docker:publish" refuses to run, before building anything, when REGISTRY_IMAGE is empty, when VERSION is not an exact tag "vX.Y.Z" or "vX.Y.Z-<pre>", or when the work tree is dirty
And the build is "docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=<tag>"; the tag is baked in through the existing version ldflags path, so the image's "renewcms --version" prints the tag
And tags pushed for vX.Y.Z-<pre>: "vX.Y.Z-<pre>" and "X.Y.Z-<pre>" and the moving tag "<pre-name>" (for example "beta"); "latest" is NOT moved
And tags pushed for vX.Y.Z: "vX.Y.Z", "X.Y.Z", "X.Y", and "latest"
And the maintainer authenticates beforehand with "docker login git.floriansylvain.fr"; the task never reads, stores or echoes a password or token, and never passes credentials on a command line
And no secret, .env, signing key or JWT_SECRET is copied into the image (already enforced by .dockerignore; checked again here)
And no image signing or attestation is part of this slice; the Ed25519 key signs release binaries only
And "task docker:publish -- --dry-run" builds for the host platform only with "--load", pushes nothing, needs no login and prints the tags it would push
And docker-compose.yml uses "image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}" (the default tag is "beta" because "latest" does not exist until the first stable release; it is changed to "latest" then) and its "build:" block moves to docker-compose.build.yml, used as "docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build"
And docs/docker.md, docs/deployment.md and README.md document pull-based upgrade, the build override, the tag scheme and the maintainer publish steps; no comments in any file
And the real push is a manual item: it needs a registry account and network
Scenario Outline: Tags for a version
Given VERSION is "<version>"
When "task docker:publish -- --dry-run" runs
Then it prints exactly these tags: <tags>
Examples:
| version | tags |
| v2.9.0 | v2.9.0, 2.9.0, 2.9, latest |
| v2.9.0-beta | v2.9.0-beta, 2.9.0-beta, beta |
| v2.9.0-rc.1 | v2.9.0-rc.1, 2.9.0-rc.1, rc |
Scenario Outline: Refusals before any build
Given <condition>
When "task docker:publish" runs
Then it exits non-zero with a message naming the problem and docker is not invoked
Examples:
| condition |
| REGISTRY_IMAGE is empty |
| VERSION is "dev" |
| VERSION is "2.9.0" (no leading v) |
| the work tree has uncommitted changes |
Scenario: Dry run builds and loads a working image
When "task docker:publish -- --dry-run" runs with VERSION "v2.9.0-beta"
Then an image is loaded locally for the host platform and no registry is contacted
And running it prints "v2.9.0-beta" for --version
And it serves GET /healthz as ok with the env from docker.env.example
And it runs as the non-root user with the data volume as in docker-deployment
Scenario: No secrets in the image
Given the dry-run image
Then "docker history" and a file listing show no .env, no release.key, no JWT_SECRET value and no SMTP password
Scenario: Compose pulls by default
Given docker-compose.yml with RENEWCMS_IMAGE unset
Then "docker compose config" shows the image "git.floriansylvain.fr/fsylvain/renewcms:beta" and no build section
And with RENEWCMS_IMAGE set to a tag, that tag is used
And with docker-compose.build.yml added, the build section is present and the image builds from the local Dockerfile
Scenario: Upgrade is a pull
Given a running stack on tag "v2.8.0-beta" with data in the volume
When the operator runs "docker compose pull && docker compose up -d" after a newer tag is pushed
Then the container is recreated on the new image and the articles, uploads and database in the volume are intact
And GET /v1/settings reports the new version
Scenario: Docs say the same thing
Then docs/docker.md, docs/deployment.md and README.md each show the pull upgrade, the tag scheme and the maintainer flow, and none claims CI exists
+29
View File
@@ -0,0 +1,29 @@
# QA: Docker image publishing
Everything runs on the real local Docker daemon, with no registry push. On Windows use MSYS_NO_PATHCONV=1 under Git Bash and put the Docker bin on PATH (memory: docker-windows-gotchas).
## Automated / scripted
1. Refusals: run "task docker:publish" with REGISTRY_IMAGE empty, VERSION=dev, VERSION=2.9.0, and a dirty tree; assert non-zero exit and that no docker build started (compare "docker buildx ls" and the image list before and after).
2. Tag table: run "task docker:publish -- --dry-run" with VERSION=v2.9.0, v2.9.0-beta and v2.9.0-rc.1 (the VERSION override is accepted only with --dry-run); compare the printed tags with the feature.
3. Dry run: the image is loaded; "docker run --rm <image> --version" prints the tag; start the stack from docker.env.example and check GET /healthz is ok, and "docker exec <ctr> id -u" is not 0.
4. Secrets: "docker history --no-trunc" and a file search for .env and release.key inside the image are clean; grep the layers for the sample JWT_SECRET value.
5. Compose: "docker compose config" with and without RENEWCMS_IMAGE, and with the build override.
6. Upgrade simulation: tag two local images v-old and v-new, run the stack on v-old, write an article, switch RENEWCMS_IMAGE to v-new, "docker compose up -d", and verify the article and the new version.
7. Docs: grep the three docs for "docker compose pull", the tag scheme, and the absence of CI claims.
8. No credentials: grep Taskfile.yml and docs for token or password literals; the task must not call "docker login".
## Manual (user performs; needs the registry)
- `docker login git.floriansylvain.fr`; "task docker:publish" with a real tag; confirm "docker buildx imagetools inspect <image>:<tag>" lists linux/amd64 and linux/arm64.
- On a clean machine, "docker compose pull && docker compose up -d" against the pushed image; confirm /settings shows the new version and the container guidance from container_update_guidance.
- Confirm the moving "beta" tag points at the intended digest and that "latest" did not move for a prerelease.
## Results
- 1 Refusals: PASS (coder and hardener runs).
- 2 Tag table: PASS (v2.9.0, v2.9.0-beta, v2.9.0-rc.1; re-seen with v2.9.0-beta and v2.9.1-beta, tags "vX, X, beta").
- 3 Dry run: PASS (--version prints the tag, /healthz ok, uid 10001).
- 4 Secrets: PASS.
- 5 Compose: PASS.
- 6 Upgrade simulation: PASS. Local images v2.9.0-beta and v2.9.1-beta, stack on the old tag, register + verify (mail captured over TLS by Mailpit) + login, article created (201), RENEWCMS_IMAGE switched, "docker compose up -d" recreated the container on the same volume. Old cookie still accepted, login 200, article 1 intact, GET /v1/settings reports v2.9.1-beta (was v2.9.0-beta).
- 7 Docs: PASS.
- 8 No credentials: PASS.
- Manual items: NOT RUN (need the registry): docker login, real multi-arch push and imagetools inspect, "docker compose pull" against the pushed image, moving beta/latest tag checks, builder failure paths of a real run.