feat: complete dockerization (optional)
This commit is contained in:
1 parent
3e01c29657
commit
d275ba3c14
38 files changed
+1239
-15
No files matched your search
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.env
|
||||
docker.env
|
||||
tmp/
|
||||
bin/
|
||||
data/
|
||||
node_modules
|
||||
web/node_modules
|
||||
internal/infrastructure/assets/dist
|
||||
.task/
|
||||
.claude/
|
||||
.idea
|
||||
.vscode
|
||||
*.db
|
||||
*.exe
|
||||
@@ -15,3 +15,4 @@ AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
TRUSTED_PROXIES=
|
||||
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
||||
RENEWCMS_CONTAINER=false
|
||||
@@ -12,6 +12,7 @@ dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
docker.env
|
||||
|
||||
# Generated files
|
||||
tmp/
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
FROM oven/bun:1-alpine AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
RUN bun run build-only
|
||||
|
||||
FROM golang:1.27.1-alpine AS server
|
||||
ARG VERSION=dev
|
||||
ENV CGO_ENABLED=0
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
|
||||
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN addgroup -S -g 10001 renewcms \
|
||||
&& adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \
|
||||
&& mkdir /data \
|
||||
&& chown renewcms:renewcms /data
|
||||
COPY --from=server /out/renewcms /usr/local/bin/renewcms
|
||||
ENV RENEWCMS_CONTAINER=true
|
||||
USER renewcms
|
||||
WORKDIR /data
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
|
||||
ENTRYPOINT ["/usr/local/bin/renewcms"]
|
||||
@@ -15,6 +15,16 @@ cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||
|
||||
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
|
||||
|
||||
### Run with Docker
|
||||
|
||||
```sh
|
||||
cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||
docker compose pull
|
||||
docker compose up -d # http://localhost:8080
|
||||
```
|
||||
|
||||
Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**.
|
||||
|
||||
### Updates from the browser
|
||||
|
||||
On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
|
||||
@@ -35,6 +45,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|
||||
|---|---|
|
||||
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
|
||||
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
|
||||
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
|
||||
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
|
||||
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
|
||||
|
||||
|
||||
+126
@@ -170,6 +170,132 @@ tasks:
|
||||
cmds:
|
||||
- ./bin/{{.BINARY}}{{exeExt}}
|
||||
|
||||
docker:build:
|
||||
desc: Build the Docker image tagged with the git describe version and latest
|
||||
cmds:
|
||||
- docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest .
|
||||
|
||||
docker:run:
|
||||
desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080
|
||||
cmds:
|
||||
- docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest
|
||||
|
||||
docker:up:
|
||||
desc: Build from the local sources and start the Compose service in the background
|
||||
env:
|
||||
VERSION: '{{.VERSION}}'
|
||||
cmds:
|
||||
- docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
|
||||
docker:pull:
|
||||
desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag)
|
||||
cmds:
|
||||
- docker compose pull
|
||||
|
||||
docker:upgrade:
|
||||
desc: Pull the published image and recreate the Compose service on it
|
||||
cmds:
|
||||
- docker compose pull
|
||||
- docker compose up -d
|
||||
|
||||
docker:down:
|
||||
desc: Stop and remove the Compose service (the data volume is kept)
|
||||
cmds:
|
||||
- docker compose down
|
||||
|
||||
docker:logs:
|
||||
desc: Follow the Compose service logs
|
||||
cmds:
|
||||
- docker compose logs -f renewcms
|
||||
|
||||
docker:publish:
|
||||
desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]'
|
||||
vars:
|
||||
REGISTRY_IMAGE:
|
||||
sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}"
|
||||
cmds:
|
||||
- cmd: |
|
||||
IMAGE='{{.REGISTRY_IMAGE}}'
|
||||
V='{{.VERSION}}'
|
||||
ARGS='{{.CLI_ARGS}}'
|
||||
fail() { echo "docker:publish refused: $1" >&2; exit 1; }
|
||||
DRY=no
|
||||
case "$ARGS" in
|
||||
"") ;;
|
||||
"--dry-run") DRY=yes ;;
|
||||
*) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;;
|
||||
esac
|
||||
[ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty"
|
||||
case "$V" in
|
||||
v*) ;;
|
||||
*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
|
||||
esac
|
||||
R="${V#v}"
|
||||
CORE="${R%%-*}"
|
||||
PRE=""
|
||||
[ "$CORE" = "$R" ] || PRE="${R#*-}"
|
||||
MAJOR="${CORE%%.*}"
|
||||
REST="${CORE#*.}"
|
||||
MINOR="${REST%%.*}"
|
||||
PATCH="${REST#*.}"
|
||||
for N in "$MAJOR" "$MINOR" "$PATCH"; do
|
||||
case "$N" in
|
||||
""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
|
||||
esac
|
||||
done
|
||||
[ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>"
|
||||
if [ "$R" != "$CORE" ]; then
|
||||
case "$PRE" in
|
||||
""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
|
||||
esac
|
||||
PRENAME="${PRE%%[!A-Za-z]*}"
|
||||
[ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
|
||||
fi
|
||||
if [ "$DRY" = no ]; then
|
||||
[ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
|
||||
[ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
|
||||
fi
|
||||
TAGS="$V $R"
|
||||
if [ -n "$PRE" ]; then
|
||||
TAGS="$TAGS $PRENAME"
|
||||
else
|
||||
TAGS="$TAGS $MAJOR.$MINOR latest"
|
||||
fi
|
||||
ARGS_TAGS=""
|
||||
LIST=""
|
||||
for T in $TAGS; do
|
||||
ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
|
||||
LIST="$LIST, $T"
|
||||
done
|
||||
echo "image: $IMAGE"
|
||||
echo "tags: ${LIST#, }"
|
||||
if [ "$DRY" = yes ]; then
|
||||
echo "dry run: building for the host platform only, loading locally, pushing nothing"
|
||||
docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
|
||||
exit 0
|
||||
fi
|
||||
BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
|
||||
STORE="$(docker info 2>&1)"
|
||||
case "$BUILDER" in
|
||||
*linux/amd64*) ;;
|
||||
*) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
|
||||
esac
|
||||
case "$BUILDER" in
|
||||
*linux/arm64*) ;;
|
||||
*) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
|
||||
esac
|
||||
case "$BUILDER" in
|
||||
*"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
|
||||
*)
|
||||
case "$STORE" in
|
||||
*io.containerd.snapshotter*) ;;
|
||||
*) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
|
||||
silent: true
|
||||
|
||||
clean:
|
||||
desc: Remove build output, the built SPA and tmp/
|
||||
cmds:
|
||||
|
||||
@@ -151,7 +151,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
|
||||
UpdateHelper: update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)),
|
||||
UpdateHelper: update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
|
||||
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
|
||||
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
|
||||
StagedRunner: update.NewExecStagedBinaryRunner(),
|
||||
@@ -197,7 +197,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
|
||||
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
|
||||
|
||||
@@ -49,6 +49,7 @@ var envDefaults = map[string]string{
|
||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||
"TRUSTED_PROXIES": "",
|
||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||
"RENEWCMS_CONTAINER": "false",
|
||||
}
|
||||
|
||||
const versionFlag = "--version"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
renewcms:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
image: renewcms:${VERSION:-dev}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
renewcms:
|
||||
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- docker.env
|
||||
environment:
|
||||
DB_FILE: /data/renewcms.db
|
||||
UPLOAD_DIR: /data/uploads
|
||||
RENEWCMS_CONTAINER: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- renewcms-data:/data
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
renewcms-data:
|
||||
@@ -0,0 +1,13 @@
|
||||
PUBLIC_URL=https://cms.example.com
|
||||
JWT_SECRET=replace-with-output-of-openssl-rand-hex-32
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_LOGIN=noreply@example.com
|
||||
SMTP_PASSWORD=replace-me
|
||||
SMTP_SENDER=noreply@example.com
|
||||
|
||||
ENVIRONMENT=production
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
TRUSTED_PROXIES=
|
||||
@@ -30,6 +30,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
|
||||
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
+3
-1
@@ -10,6 +10,8 @@ RenewCMS ships as **one self-contained executable**. It serves:
|
||||
|
||||
Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
|
||||
|
||||
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
|
||||
|
||||
## 1. Build
|
||||
|
||||
On any machine with the [dev prerequisites](development.md#prerequisites):
|
||||
@@ -33,7 +35,7 @@ To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, wi
|
||||
|
||||
### Update checks
|
||||
|
||||
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server.
|
||||
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server. In Docker (`RENEWCMS_CONTAINER=true`) browser updates are not offered; Settings tells the owner to pull a new image instead, see [docker.md](docker.md).
|
||||
|
||||
### Update downloads
|
||||
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# Docker
|
||||
|
||||
RenewCMS runs as a container. The image is published to the registry at `git.floriansylvain.fr/fsylvain/renewcms`, so site operators pull it and never build from source; it can also be built from this repository. The image holds only the single binary (SPA embedded) on Alpine, runs as a non-root user (uid 10001) and keeps all state on one volume mounted at `/data`. It reuses the configuration contract described in [configuration.md](configuration.md); there are no Docker-specific variables.
|
||||
|
||||
Requirements: Docker with Compose v2. [task](https://taskfile.dev) is optional for operators and needed to build from source or to publish. Building needs no local Go or Bun. The image build bundles the SPA with `vite build` only; the `vue-tsc` type-check is not run inside the image (it does not work under the Bun-only build image), so keep running `bun run type-check` locally.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Copy the example env file and edit it:
|
||||
|
||||
```sh
|
||||
cp docker.env.example docker.env
|
||||
```
|
||||
|
||||
Set `PUBLIC_URL`, the `SMTP_*` values and a generated secret (`openssl rand -hex 32`) for `JWT_SECRET`. `docker.env` is git-ignored and excluded from the build context, so it never ends up in an image. Never commit it.
|
||||
|
||||
2. Pull and start the published image:
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`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 (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v2.9.0-beta`.
|
||||
|
||||
To build from the local sources instead, see [Building from source](#building-from-source).
|
||||
|
||||
3. Check it: `curl http://localhost:8080/healthz` returns `ok`, and `http://localhost:8080/` serves the SPA.
|
||||
|
||||
Other tasks: `task docker:pull`, `task docker:upgrade` (pull, then `docker compose up -d`), `task docker:up` (local build, see below), `task docker:build` (image only, tagged with the `git describe` version and `latest`), `task docker:run` (one-off foreground run on the volume `renewcms-data`, without Compose), `task docker:logs` and `task docker:down`.
|
||||
|
||||
If a required variable is missing the container exits non-zero and the log names it, for example `Environment variable JWT_SECRET is not set`.
|
||||
|
||||
### Local trial over plain HTTP
|
||||
|
||||
`ENVIRONMENT` defaults to `production`, which makes the auth cookie `Secure`: browsers do not store it over `http://`, so login appears to fail. For a local trial on `http://localhost:8080`, set `ENVIRONMENT=development` and `PUBLIC_URL=http://localhost:8080` in `docker.env`. Use `production` only behind HTTPS.
|
||||
|
||||
## Building from source
|
||||
|
||||
`docker-compose.build.yml` adds the `build:` section and tags the result `renewcms:${VERSION:-dev}`. Use it as an override:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
`task docker:up` does the same and passes the `git describe` version as `VERSION`. To check what Compose resolves, run `docker compose config`: without the override it shows the registry image and no `build` section, with it the `build` section is present.
|
||||
|
||||
## Image tags
|
||||
|
||||
| Release | Tags pushed |
|
||||
|---|---|
|
||||
| `vX.Y.Z` (stable) | `vX.Y.Z`, `X.Y.Z`, `X.Y`, `latest` |
|
||||
| `vX.Y.Z-<pre>` (for example `v2.9.0-beta`, `v2.9.0-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
|
||||
|
||||
A pre-release never moves `latest`. Images are published for `linux/amd64` and `linux/arm64`. `renewcms --version` in the image prints the exact release tag. Images are not signed; the Ed25519 key signs the release binaries only.
|
||||
|
||||
## Environment
|
||||
|
||||
Compose reads `docker.env` through `env_file` and sets `DB_FILE=/data/renewcms.db` and `UPLOAD_DIR=/data/uploads` itself. Everything in [configuration.md](configuration.md) can go in `docker.env`. To publish another host port, change the left side of `ports` in `docker-compose.yml`; keep `PORT` at 8080 unless you also change the healthcheck.
|
||||
|
||||
The container receives SIGTERM on stop, and RenewCMS shuts down gracefully within 10 seconds. Compose uses `stop_grace_period: 15s`. The healthcheck calls `/healthz` inside the container.
|
||||
|
||||
## Data volume
|
||||
|
||||
State lives on the named volume `renewcms-data` (Compose prefixes it with the project name, usually `renewcms_renewcms-data`; check with `docker volume ls`). It holds the SQLite database, uploads, and update records.
|
||||
|
||||
- `docker compose down` removes the container and keeps the volume. The data survives and `docker compose up -d` brings it back.
|
||||
- **`docker compose down -v` deletes the volume and all data.** The next start is an empty instance.
|
||||
|
||||
### Backup
|
||||
|
||||
Replace `VOLUME` with the real volume name. The image has no `sqlite3`, so stop the service for a consistent copy:
|
||||
|
||||
```sh
|
||||
docker compose stop
|
||||
docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine tar czf /backup/renewcms-backup.tar.gz -C /data .
|
||||
docker compose start
|
||||
```
|
||||
|
||||
### Restore into a fresh volume
|
||||
|
||||
```sh
|
||||
docker compose down -v
|
||||
docker volume create VOLUME
|
||||
docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine sh -c "tar xzf /backup/renewcms-backup.tar.gz -C /data && chown -R 10001:10001 /data"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Reverse proxy and TLS
|
||||
|
||||
With `ENVIRONMENT=production` the site must be served over HTTPS, so put a reverse proxy in front and set `PUBLIC_URL` to the public `https://` address. Bind the published port to the host only when the proxy runs on the same host, by changing `ports` to `"127.0.0.1:8080:8080"`.
|
||||
|
||||
Caddy on the host:
|
||||
|
||||
```caddyfile
|
||||
cms.example.com {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nginx on the host:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name cms.example.com;
|
||||
client_max_body_size 20m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set `TRUSTED_PROXIES` to the proxy address as the container sees it, otherwise the login rate limiter ignores `X-Real-IP` and counts every client as the proxy. With a proxy on the host and the default bridge network, that is the bridge gateway (check with `docker network inspect`, often `172.17.0.1` or the Compose network gateway). With a proxy container on the same Compose network, it is that container's IP. The value is a semicolon-separated list of exact IPs, no CIDR. See [deployment.md](deployment.md#4-put-https-in-front).
|
||||
|
||||
## SMTP
|
||||
|
||||
RenewCMS sends registration and password-reset mail through the `SMTP_*` variables. Use your provider's host and port in `docker.env`.
|
||||
|
||||
To use a mail server running on the Docker host, set `SMTP_HOST=host.docker.internal`. Docker Desktop resolves that name. On Linux add the mapping to the service in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
```
|
||||
|
||||
The host MTA must listen on an address reachable from the container, not only on `127.0.0.1`.
|
||||
|
||||
If SMTP is unreachable, the server still starts. Only the actions that send mail (registration, password reset, invitations) fail, and the error appears in `docker compose logs renewcms`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Back up the volume (see above).
|
||||
2. Pull the new image and recreate the container on the same volume:
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`task docker:upgrade` runs both. The schema is migrated at startup, and there is no down-migration.
|
||||
3. Check `docker compose logs renewcms` for the new version line, or `docker run --rm git.floriansylvain.fr/fsylvain/renewcms:beta --version`. The owner's Settings page also shows the running version.
|
||||
|
||||
To roll back, set `RENEWCMS_IMAGE` to the previous tag, run `docker compose up -d`, and restore the backup if the schema changed.
|
||||
|
||||
If you built from source, upgrade with `git pull` and `task docker:up` instead.
|
||||
|
||||
## Maintainer: publishing
|
||||
|
||||
Publishing is a manual, maintainer-run task, like `task release`. There is no CI pipeline.
|
||||
|
||||
1. Tag the release and keep the work tree clean:
|
||||
|
||||
```sh
|
||||
git tag v2.9.0-beta
|
||||
```
|
||||
|
||||
2. Log in once to the registry. The task never reads or stores credentials:
|
||||
|
||||
```sh
|
||||
docker login git.floriansylvain.fr
|
||||
```
|
||||
|
||||
3. Make sure a buildx builder can build both platforms. Check with `docker buildx ls`. With the containerd image store (the Docker Desktop default) the default builder already works. Otherwise create one, and on a Linux host install the QEMU emulators:
|
||||
|
||||
```sh
|
||||
docker buildx create --use
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
```
|
||||
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
task docker:publish
|
||||
```
|
||||
|
||||
`REGISTRY_IMAGE` defaults to `git.floriansylvain.fr/fsylvain/renewcms` and `VERSION` to `git describe --tags`. The task 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>` on HEAD, when the work tree is dirty, or when the builder cannot build `linux/amd64` and `linux/arm64`. It prints the tags it pushes.
|
||||
|
||||
### Dry run
|
||||
|
||||
```sh
|
||||
task docker:publish -- --dry-run
|
||||
task docker:publish VERSION=v2.9.0-rc.1 -- --dry-run
|
||||
```
|
||||
|
||||
The dry run builds for the host platform only, loads the image into the local Docker, pushes nothing and needs no login. It prints the tags a real run would push. A `VERSION` override is accepted only with `--dry-run`, and a dirty tree is allowed there. Remove the loaded images afterwards with `docker rmi`.
|
||||
|
||||
### After pushing
|
||||
|
||||
- `docker buildx imagetools inspect git.floriansylvain.fr/fsylvain/renewcms:<tag>` lists `linux/amd64` and `linux/arm64`.
|
||||
- The moving tag (`beta`) points at the new digest, and `latest` did not move for a pre-release.
|
||||
- On a clean machine, `docker compose pull && docker compose up -d` starts the new image and Settings shows the new version.
|
||||
|
||||
## Limitations
|
||||
|
||||
Browser updates are unsupported in Docker (they need Linux with systemd, and a binary replaced inside a container is lost when it is recreated). The image and `docker-compose.yml` set `RENEWCMS_CONTAINER=true`, so the owner's Settings page shows when a newer version exists and tells them to pull a new image: `docker compose pull && docker compose up -d`. There is no Update or Restart button. Upgrade by pulling the image as above, or by rebuilding it if you build from source.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -60,6 +60,7 @@ type UpdateStatus struct {
|
||||
Error *string `json:"error"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Container bool `json:"container"`
|
||||
}
|
||||
|
||||
func NewResult(release Release, checkedAt time.Time) CheckResult {
|
||||
|
||||
@@ -33,6 +33,7 @@ type Settings struct {
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
}
|
||||
|
||||
func (s Settings) IsProduction() bool {
|
||||
@@ -76,6 +77,7 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
},
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package update
|
||||
|
||||
import domain "RenewCMS/internal/domain/update"
|
||||
|
||||
type containerHelperDetector struct {
|
||||
inner domain.HelperDetector
|
||||
container bool
|
||||
}
|
||||
|
||||
func NewContainerHelperDetector(inner domain.HelperDetector, container bool) domain.HelperDetector {
|
||||
return containerHelperDetector{inner: inner, container: container}
|
||||
}
|
||||
|
||||
func (d containerHelperDetector) Installed() bool {
|
||||
return !d.container && d.inner.Installed()
|
||||
}
|
||||
@@ -8,15 +8,19 @@ import (
|
||||
type GetUpdateStatusUseCase struct {
|
||||
status updateStatusBuilder
|
||||
helper update.HelperDetector
|
||||
container bool
|
||||
}
|
||||
|
||||
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *GetUpdateStatusUseCase {
|
||||
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, container bool, now func() time.Time) *GetUpdateStatusUseCase {
|
||||
return &GetUpdateStatusUseCase{
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
helper: helper,
|
||||
container: container,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
|
||||
return g.status.build(isOwner && g.helper.Installed())
|
||||
status := g.status.build(isOwner && g.helper.Installed())
|
||||
status.Container = g.container
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
infraupdate "RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type containerHarness struct {
|
||||
*updateHarness
|
||||
fetcher *fakeAssetFetcher
|
||||
requests *fakeRestartRequestStore
|
||||
jobs *fakeJobStore
|
||||
}
|
||||
|
||||
func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *containerHarness {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
rig := newDownloadRig(t)
|
||||
rig.helper.installed = helperInstalled
|
||||
helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
feed := &fakeReleaseFeed{release: update.Release{
|
||||
Tag: "v2.3.0-beta",
|
||||
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
|
||||
PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
|
||||
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
|
||||
}}
|
||||
rig.store.found = true
|
||||
start := useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
|
||||
Store: rig.store,
|
||||
Jobs: rig.jobs,
|
||||
Helper: helper,
|
||||
Results: rig.results,
|
||||
Requests: rig.requests,
|
||||
Fetcher: rig.fetcher,
|
||||
Verifier: nil,
|
||||
Runner: rig.runner,
|
||||
Backup: rig.backup,
|
||||
Staging: rig.staging,
|
||||
Logger: rig.logger,
|
||||
CurrentVersion: updateRunning,
|
||||
Platform: rig.platform,
|
||||
FeedURL: downloadFeedURL,
|
||||
Now: now,
|
||||
})
|
||||
routes := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
|
||||
StartUpdateDownloadUseCase: start,
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, now),
|
||||
}
|
||||
return &containerHarness{
|
||||
updateHarness: &updateHarness{router: api.NewSystemRouter(routes), token: token, feed: feed, store: rig.store},
|
||||
fetcher: rig.fetcher,
|
||||
requests: rig.requests,
|
||||
jobs: rig.jobs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerUpdateGuidanceHTTP(t *testing.T) {
|
||||
t.Run("The status carries the container flag", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
flag bool
|
||||
}{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}}
|
||||
for _, tc := range cases {
|
||||
t.Run("value "+tc.value, func(t *testing.T) {
|
||||
t.Setenv("SMTP_PORT", "1")
|
||||
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1")
|
||||
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1")
|
||||
t.Setenv("RENEWCMS_CONTAINER", tc.value)
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.Container != tc.flag {
|
||||
t.Fatalf("Container = %v, want %v", settings.Container, tc.flag)
|
||||
}
|
||||
h := newContainerHarness(t, settings.Container, false)
|
||||
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
|
||||
|
||||
assertUpdateKeys(t, body)
|
||||
if body["container"] != tc.flag {
|
||||
t.Errorf("container = %v, want %v", body["container"], tc.flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Container mode disables apply even with a helper", func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", userId))
|
||||
|
||||
if body["can_apply"] != false || body["container"] != true {
|
||||
t.Errorf("%s: body = %v", name, body)
|
||||
}
|
||||
if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["notes"] == nil {
|
||||
t.Errorf("%s: body = %v", name, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Host installs are unchanged", func(t *testing.T) {
|
||||
h := newContainerHarness(t, false, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
|
||||
|
||||
if body["container"] != false || body["can_apply"] != true {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The check still works in a container", func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.found = false
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
|
||||
body := decodeUpdateBody(t, rec)
|
||||
if rec.Code != http.StatusOK || body["available"] != true || body["container"] != true {
|
||||
t.Fatalf("status = %d body = %v", rec.Code, body)
|
||||
}
|
||||
if h.store.saves != 1 || h.store.result.Latest != "v2.3.0-beta" {
|
||||
t.Errorf("saves = %d result = %+v", h.store.saves, h.store.result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Download and restart are refused in a container", func(t *testing.T) {
|
||||
for _, path := range []string{"/update/download", "/update/restart"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, path, updateOwnerId)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found := h.jobs.Load(); found {
|
||||
t.Error("no job may start")
|
||||
}
|
||||
if len(h.fetcher.calls) != 0 {
|
||||
t.Errorf("fetcher calls = %v", h.fetcher.calls)
|
||||
}
|
||||
if h.requests.writes != 0 {
|
||||
t.Errorf("restart request writes = %d", h.requests.writes)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainerHelperDetectorTruthTable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
container, inner, want bool
|
||||
}{{false, false, false}, {false, true, true}, {true, false, false}, {true, true, false}} {
|
||||
got := infraupdate.NewContainerHelperDetector(&fakeHelperDetector{installed: tc.inner}, tc.container).Installed()
|
||||
if got != tc.want {
|
||||
t.Errorf("container=%v inner=%v: Installed() = %v, want %v", tc.container, tc.inner, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStatusUseCaseCanApplyTruthTable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
isOwner, container, helper, want bool
|
||||
}{
|
||||
{true, false, true, true},
|
||||
{false, false, true, false},
|
||||
{true, true, true, false},
|
||||
{true, false, false, false},
|
||||
{false, true, true, false},
|
||||
} {
|
||||
rig := newDownloadRig(t)
|
||||
rig.helper.installed = tc.helper
|
||||
status := useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, infraupdate.NewContainerHelperDetector(rig.helper, tc.container), rig.results, rig.requests, updateRunning, tc.container, func() time.Time { return updateTestCheckedAt }).GetStatus(tc.isOwner)
|
||||
if status.CanApply != tc.want || status.Container != tc.container {
|
||||
t.Errorf("%+v: CanApply=%v Container=%v", tc, status.CanApply, status.Container)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ func (r *downloadRig) rebuild() {
|
||||
FeedURL: downloadFeedURL,
|
||||
Now: func() time.Time { return updateTestCheckedAt },
|
||||
})
|
||||
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, time.Now)
|
||||
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now)
|
||||
}
|
||||
|
||||
func (r *downloadRig) checksumsFor(name string, content []byte) string {
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
updateRunning = "v2.2.0-beta"
|
||||
)
|
||||
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "container", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
|
||||
type updateHarness struct {
|
||||
router http.Handler
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func newUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, running string) *useCases.GetUpdateStatusUseCase {
|
||||
return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, time.Now)
|
||||
return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, false, time.Now)
|
||||
}
|
||||
|
||||
type fakeRestartRequestStore struct {
|
||||
|
||||
@@ -58,7 +58,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
|
||||
Token: h.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func newRestartRig(running string) *restartRig {
|
||||
clock: &fakeClock{now: restartBaseTime},
|
||||
}
|
||||
rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
|
||||
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
|
||||
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, false, rig.clock.Now)
|
||||
return rig
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
line: string
|
||||
}>()
|
||||
showNote?: boolean
|
||||
}>(),
|
||||
{ showNote: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
@@ -50,7 +54,7 @@ async function copyLine() {
|
||||
{{ buttonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
<span v-if="showNote" class="text-xs/5 text-muted">{{
|
||||
t('They run this once on the server. After that, Update and Restart appear here.')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import type { UserRole } from '@/composables/useApi'
|
||||
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
||||
import {
|
||||
CONTAINER_UPDATE_COMMAND,
|
||||
ENABLE_BROWSER_UPDATES_COMMAND,
|
||||
VERIFYING_ANNOUNCEMENT,
|
||||
browserUpdatesDocsUrl,
|
||||
containerUpdatesDocsUrl,
|
||||
displayVersion,
|
||||
downloadAnnouncement,
|
||||
formatDownloadProgress,
|
||||
@@ -383,6 +385,21 @@ const block = computed<Block>(() => {
|
||||
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
|
||||
}
|
||||
}
|
||||
if (showContainerGuidance.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
get title() {
|
||||
return t('Version {0} is available', [newVersion.value])
|
||||
},
|
||||
get text() {
|
||||
return t(
|
||||
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
|
||||
[props.status.current],
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (props.status.available && isOwner.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
@@ -435,9 +452,15 @@ const restartAvailable = computed(
|
||||
() => canApplyView.value && (props.status.state === 'idle' || isDone.value),
|
||||
)
|
||||
|
||||
const showContainerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
|
||||
)
|
||||
|
||||
const showServerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value &&
|
||||
!props.status.container &&
|
||||
!props.status.can_apply &&
|
||||
props.status.available &&
|
||||
props.phase === 'idle',
|
||||
@@ -550,7 +573,10 @@ function onAction(button: ActionButton) {
|
||||
|
||||
const showNotes = computed(
|
||||
() =>
|
||||
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
|
||||
showServerGuidance.value ||
|
||||
showContainerGuidance.value ||
|
||||
applyView.value === 'available' ||
|
||||
applyView.value === 'ready',
|
||||
)
|
||||
|
||||
const notes = computed(() => {
|
||||
@@ -625,6 +651,12 @@ const notes = computed(() => {
|
||||
|
||||
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
|
||||
|
||||
<ServerCommand
|
||||
v-if="showContainerGuidance"
|
||||
:line="CONTAINER_UPDATE_COMMAND"
|
||||
:show-note="false"
|
||||
/>
|
||||
|
||||
<div v-if="rowButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<UButton
|
||||
v-for="button in rowButtons"
|
||||
@@ -660,6 +692,17 @@ const notes = computed(() => {
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="showContainerGuidance && status.release_url"
|
||||
:href="containerUpdatesDocsUrl(status.release_url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('How to update') }}
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="isDone && status.release_url"
|
||||
:href="status.release_url"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface UpdateStatus {
|
||||
error: UpdateErrorKind | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
container: boolean
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
|
||||
@@ -629,6 +629,8 @@ export const messages = {
|
||||
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.":
|
||||
'Ce site ne peut pas encore se mettre à jour depuis le navigateur. La personne qui gère votre serveur peut l’activer ou mettre à jour RenewCMS pour vous.',
|
||||
'How to update': 'Comment mettre à jour',
|
||||
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.':
|
||||
'Vous avez {0}. Ce site fonctionne dans Docker : il se met à jour en récupérant une nouvelle image. Exécutez les commandes ci-dessous sur le serveur.',
|
||||
'For whoever manages the server': 'Pour la personne qui gère le serveur',
|
||||
'They run this once on the server. After that, Update and Restart appear here.':
|
||||
'Cette commande s’exécute une fois sur le serveur. Les actions de mise à jour et de redémarrage apparaissent ensuite ici.',
|
||||
|
||||
@@ -72,13 +72,23 @@ export function downloadAnnouncement(version: string, percent: number) {
|
||||
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
|
||||
|
||||
const BROWSER_UPDATES_DOCS_PATH = '/src/branch/main/docs/deployment.md#browser-updates'
|
||||
const CONTAINER_UPDATES_DOCS_PATH = '/src/branch/main/docs/docker.md'
|
||||
export const CONTAINER_UPDATE_COMMAND = 'docker compose pull && docker compose up -d'
|
||||
const RELEASES_SEGMENT = /^(.*)\/releases\//
|
||||
|
||||
export function browserUpdatesDocsUrl(releaseUrl: string) {
|
||||
return docsUrl(releaseUrl, BROWSER_UPDATES_DOCS_PATH)
|
||||
}
|
||||
|
||||
export function containerUpdatesDocsUrl(releaseUrl: string) {
|
||||
return docsUrl(releaseUrl, CONTAINER_UPDATES_DOCS_PATH)
|
||||
}
|
||||
|
||||
function docsUrl(releaseUrl: string, docsPath: string) {
|
||||
try {
|
||||
const url = new URL(releaseUrl)
|
||||
const repository = RELEASES_SEGMENT.exec(url.pathname)
|
||||
return repository ? `${url.origin}${repository[1]}${BROWSER_UPDATES_DOCS_PATH}` : releaseUrl
|
||||
return repository ? `${url.origin}${repository[1]}${docsPath}` : releaseUrl
|
||||
} catch {
|
||||
return releaseUrl
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ function neverCheckedStatus(): UpdateStatus {
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
container: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi, type UserRole } from '@/composables/useApi'
|
||||
import type { UpdateStatus } from '@/composables/useUpdate'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import {
|
||||
RUNNING_VERSION,
|
||||
STATUS_PATH,
|
||||
findButton,
|
||||
newerAvailable,
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const CONTAINER_COMMAND = 'docker compose pull && docker compose up -d'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.status = 'ready'
|
||||
const request = vi.fn((path: string) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return Promise.resolve({ version: RUNNING_VERSION })
|
||||
}
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role,
|
||||
}),
|
||||
request,
|
||||
})
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function updatesCard() {
|
||||
return document.body.querySelector('[data-testid="updates-card"]')!
|
||||
}
|
||||
|
||||
function cardText() {
|
||||
return updatesCard().textContent ?? ''
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings, owner, container, update available', () => {
|
||||
it('shows the pull-image guidance, the command, Copy and the docs link, with no other button', async () => {
|
||||
await mountSettings('owner', newerAvailable({ container: true }))
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
|
||||
)
|
||||
expect(updatesCard().querySelector('code')?.textContent).toBe(CONTAINER_COMMAND)
|
||||
const link = Array.from(updatesCard().querySelectorAll('a')).find(
|
||||
(anchor) => anchor.textContent?.trim() === 'How to update',
|
||||
)!
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/docker.md',
|
||||
)
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual(["What's new in 2.3.0-beta", 'Copy'])
|
||||
expect(cardText()).not.toContain('renewcms enable-browser-updates')
|
||||
expect(cardText()).not.toContain('They run this once on the server')
|
||||
expect(cardText()).not.toContain("can't update itself from the browser")
|
||||
})
|
||||
|
||||
it('copies the container command and reads Copied for 2 s', async () => {
|
||||
const writeText = stubClipboard()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountSettings('owner', newerAvailable({ container: true }))
|
||||
|
||||
findButton('Copy')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(CONTAINER_COMMAND)
|
||||
expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(findButton('Copy')?.textContent?.trim()).toBe('Copy')
|
||||
expect(findButton('Copied')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, container, up to date', () => {
|
||||
it("shows You're up to date with only Check for updates and no container hint", async () => {
|
||||
await mountSettings('owner', upToDate({ container: true }))
|
||||
|
||||
expect(cardText()).toContain("You're up to date")
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual(['Check for updates'])
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, admin, container', () => {
|
||||
it('keeps the admin view with no command block', async () => {
|
||||
await mountSettings('admin', newerAvailable({ container: true }))
|
||||
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, not container, not prepared', () => {
|
||||
it('still shows the enable-browser-updates command', async () => {
|
||||
await mountSettings('owner', newerAvailable({ container: false }))
|
||||
|
||||
expect(updatesCard().querySelector('code')?.textContent).toBe(
|
||||
'renewcms enable-browser-updates',
|
||||
)
|
||||
expect(cardText()).toContain("This site can't update itself from the browser yet.")
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
container: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user