From 64bcd0ab9f35b207ed21f41cfa05c1499ad4cbae Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:55:49 +0200 Subject: [PATCH] fix(update): target docker-compose.production.yml in dashboard update steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production installs use docker-compose.production.yml (the README's documented path, pinned GHCR images, no dev services), but the dashboard's update instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose` operates on docker-compose.yml — a different, build-based stack — so a production user who followed the steps: - never pulled/recreated their real containers (stayed on the old version, e.g. stuck on 3.44.0 after "updating" to 3.45.2), and - started the dev-only mailhog service that docker-compose.yml defines (reported restart-looping). The backend runs inside a container and can't stat the host's compose files, but docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env and docker-compose.yml does not. detectEnvironment() now derives isProductionCompose from it, and the Docker update steps prepend `-f docker-compose.production.yml` when set. The non-production branch keeps the bare commands but the warning now tells users to add `-f docker-compose.production.yml` if they installed with it. Also gates the mailhog service in docker-compose.yml behind a `dev` compose profile so a plain `docker compose up -d` never starts it (opt in with `docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes from .env), so gating is safe. Verified: `docker compose config` lists mailhog only with `--profile dev`; production compose is unchanged. Adds unit tests for the production-vs-default command generation. --- .../environmentUpdateInstructions.test.js | 58 +++++++++++++++++++ backend/src/services/environmentService.js | 31 ++++++++-- docker-compose.yml | 6 ++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 backend/__tests__/services/environmentUpdateInstructions.test.js diff --git a/backend/__tests__/services/environmentUpdateInstructions.test.js b/backend/__tests__/services/environmentUpdateInstructions.test.js new file mode 100644 index 00000000..60af4bea --- /dev/null +++ b/backend/__tests__/services/environmentUpdateInstructions.test.js @@ -0,0 +1,58 @@ +/** + * Regression tests for the Docker update instructions (environmentService). + * + * A production install (docker-compose.production.yml) must get `-f + * docker-compose.production.yml` in every update command — bare `docker compose` + * targets docker-compose.yml, a different build-based stack that also starts the + * dev-only mailhog, which left production users stranded on the old version + * (reported against 3.44.0 → 3.45.2). + */ +const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService'); + +describe('detectEnvironment — production compose detection', () => { + const orig = process.env.PICPEAK_RELEASE_CHANNEL; + afterEach(() => { + if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL; + else process.env.PICPEAK_RELEASE_CHANNEL = orig; + }); + + it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => { + process.env.PICPEAK_RELEASE_CHANNEL = 'stable'; + const env = await detectEnvironment(); + expect(env.isProductionCompose).toBe(true); + }); + + it('does not flag it when the var is absent (default docker-compose.yml)', async () => { + delete process.env.PICPEAK_RELEASE_CHANNEL; + const env = await detectEnvironment(); + expect(env.isProductionCompose).toBe(false); + }); +}); + +describe('generateUpdateInstructions — Docker commands', () => { + const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command); + + it('targets docker-compose.production.yml for a production install', () => { + const commands = cmds({ isDocker: true, isProductionCompose: true }); + expect(commands).toEqual([ + 'docker compose -f docker-compose.production.yml pull', + 'docker compose -f docker-compose.production.yml up -d', + 'docker compose -f docker-compose.production.yml logs -f backend', + ]); + // And the warning tells them where to run it. + const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2'); + expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/); + }); + + it('uses bare commands + a hint when not a production compose', () => { + const commands = cmds({ isDocker: true, isProductionCompose: false }); + expect(commands).toEqual([ + 'docker compose pull', + 'docker compose up -d', + 'docker compose logs -f backend', + ]); + const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2'); + // Still nudges production users to add -f in case detection missed. + expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/); + }); +}); diff --git a/backend/src/services/environmentService.js b/backend/src/services/environmentService.js index 9e864ada..118feff6 100644 --- a/backend/src/services/environmentService.js +++ b/backend/src/services/environmentService.js @@ -47,11 +47,23 @@ async function detectEnvironment() { type = 'standalone'; } + // Detect a production compose install. The backend runs INSIDE a container and + // cannot see the host's compose files (the image only carries backend/), so we + // can't stat docker-compose.production.yml. Instead we key off an env var the + // production compose sets in the backend environment (PICPEAK_RELEASE_CHANNEL) + // and the default docker-compose.yml does not. When present, the update + // instructions must target that file explicitly — bare `docker compose` + // operates on docker-compose.yml, a different (build-based) stack that also + // starts the dev-only mailhog and leaves the real production containers on the + // old version. + const isProductionCompose = Boolean(process.env.PICPEAK_RELEASE_CHANNEL); + return { type, isDocker, isGit, hasDockerCompose, + isProductionCompose, platform: process.platform, nodeVersion: process.version, appVersion @@ -94,25 +106,36 @@ function generateUpdateInstructions(env, targetVersion) { if (env.isDocker) { instructions.environmentName = 'Docker'; + // Production installs use docker-compose.production.yml (the file the README + // documents and the only one with pinned GHCR images + no dev-only mailhog). + // Bare `docker compose` targets docker-compose.yml instead, so a production + // user who runs it stays on the old version and gets a stray mailhog. When we + // detect a production compose (PICPEAK_RELEASE_CHANNEL set), point every + // command at that file with `-f`. + const composeFile = env.isProductionCompose ? '-f docker-compose.production.yml ' : ''; instructions.steps = [ { description: 'Pull latest images', - command: 'docker compose pull', + command: `docker compose ${composeFile}pull`, note: 'Downloads the new version images' }, { description: 'Recreate containers with new images', - command: 'docker compose up -d', + command: `docker compose ${composeFile}up -d`, note: 'Restarts containers with new version' }, { description: 'Watch logs for startup (optional)', - command: 'docker compose logs -f backend', + command: `docker compose ${composeFile}logs -f backend`, note: 'Press Ctrl+C to exit logs', optional: true } ]; - instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file'); + if (env.isProductionCompose) { + instructions.warnings.push('Run these from the directory containing your docker-compose.production.yml file.'); + } else { + instructions.warnings.push('Make sure you are in the directory containing your compose file. If you installed with docker-compose.production.yml, add `-f docker-compose.production.yml` to each command.'); + } } else if (env.isGit) { instructions.environmentName = 'Git (Development)'; instructions.steps = [ diff --git a/docker-compose.yml b/docker-compose.yml index 786c6652..37b38159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,10 +141,16 @@ services: networks: - picpeak-network + # Local mail catcher for development/testing only — never wanted in a real + # deployment. Gated behind the `dev` profile so a plain `docker compose up -d` + # does NOT start it; opt in with `docker compose --profile dev up -d`. Nothing + # depends on it (SMTP_HOST comes from .env), so gating is safe. mailhog: image: mailhog/mailhog:latest container_name: picpeak-mailhog restart: unless-stopped + profiles: + - dev ports: - "${MAILHOG_SMTP_PORT:-1025}:1025" - "${MAILHOG_UI_PORT:-8025}:8025"