mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 13:06:54 +00:00
fix(docker): move start-up into a script so an edited container still boots
The image carried its whole start-up sequence as one inline `sh -c` string containing four single-quoted echo messages. Container management UIs render Config.Cmd back into a text field and re-split it on submit with a quote-aware, escape-unaware tokenizer, so editing a container in place — the usual way to change an environment variable — shredded the quoting and left dash half an if-block to parse. The container then died before Node started, with only `TREK: 1: Syntax error: end of file unexpected (expecting "fi")` in the log: the word TREK is the stranded fragment of the first echo, reported as $0. The logic moves to server/scripts/entrypoint.sh unchanged, and CMD becomes a single bare token, which has nothing left to mangle. A unit test pins that shape — both exec-form lines parse, and every argv element stays one token free of quotes and spaces — and the Dockerfile joins the test workflow's path filter so that guard actually runs when someone edits it. Closes #2374
This commit is contained in:
@@ -51,6 +51,12 @@ on:
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
@@ -69,6 +75,12 @@ on:
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
|
||||
+12
-6
@@ -62,7 +62,8 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
|
||||
# it to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
@@ -117,9 +118,14 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start-up lives in a script, not an inline `sh -c` string: container management
|
||||
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
|
||||
# carrying quotes does not survive that round-trip (the container then dies with
|
||||
# a shell syntax error before Node starts). A CMD of one bare token does.
|
||||
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
|
||||
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
|
||||
# node. chmod is explicit because a checkout on Windows carries no exec bit.
|
||||
RUN chmod 0755 /usr/local/bin/trek-entrypoint
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["/usr/local/bin/trek-entrypoint"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Container start-up: check the image layout, correct ownership of the mounted
|
||||
# data dirs while still root, then drop to the node user.
|
||||
#
|
||||
# This has to stay a file rather than an inline `sh -c` string in CMD. Container
|
||||
# management UIs render Config.Cmd back into a text field and re-split it with a
|
||||
# quote-aware tokenizer when you edit a container in place; a command carrying
|
||||
# embedded quotes comes back mangled and the container dies with a shell syntax
|
||||
# error before Node starts. A CMD of one bare token survives that round-trip.
|
||||
set -e
|
||||
|
||||
# If the app code is missing, a volume was almost certainly mounted over /app
|
||||
# (it hides the image's node_modules + dist). Fail with actionable guidance
|
||||
# instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then
|
||||
echo 'FATAL: TREK application files are missing from the image.'
|
||||
echo 'A volume is likely mounted over /app, which hides the app code.'
|
||||
echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'
|
||||
echo 'Do NOT mount a volume at /app. See https://github.com/liketrek/TREK/wiki/Troubleshooting'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Best effort: a read-only host filesystem or a locked-down bind mount must keep
|
||||
# booting, exactly as it did before.
|
||||
chown -R node:node /app/data /app/uploads 2>/dev/null || true
|
||||
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and
|
||||
# ../node_modules resolves correctly.
|
||||
cd /app/server
|
||||
exec gosu node node --require tsconfig-paths/register dist/index.js
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Container management UIs let you edit a running container's command, and they
|
||||
// do it by rendering Config.Cmd back into a text field and re-splitting it with a
|
||||
// quote-aware tokenizer on submit. Embedded quotes never survive that round-trip:
|
||||
// an inline `sh -c "… echo 'FATAL: …' …"` comes back truncated and the container
|
||||
// dies with a shell syntax error before Node starts (#2374). The image therefore
|
||||
// keeps its start-up logic in server/scripts/entrypoint.sh and every ENTRYPOINT
|
||||
// and CMD element is a single bare token. Pin that shape so it cannot drift back.
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
|
||||
const dockerfile = fs.readFileSync(path.join(repoRoot, 'Dockerfile'), 'utf8');
|
||||
const entrypointPath = path.join(repoRoot, 'server', 'scripts', 'entrypoint.sh');
|
||||
|
||||
// Anchored at column 0 and requiring the JSON exec form, so the HEALTHCHECK's
|
||||
// indented shell-form `CMD wget -qO- … || exit 1` is not picked up.
|
||||
const readExecForms = (): string[][] =>
|
||||
[...dockerfile.matchAll(/^(?:ENTRYPOINT|CMD)[ \t]+(\[[^\n]*\])$/gm)].map((m) => JSON.parse(m[1]));
|
||||
|
||||
describe('container start-up shape', () => {
|
||||
const execForms = readExecForms();
|
||||
|
||||
it('finds both exec-form lines (guards against matching nothing or the healthcheck)', () => {
|
||||
expect(execForms).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps every ENTRYPOINT and CMD element a single token free of quotes and spaces', () => {
|
||||
for (const argv of execForms) {
|
||||
expect(argv.length).toBeGreaterThan(0);
|
||||
for (const element of argv) expect(element).toMatch(/^[^\s'"]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('entrypoint script', () => {
|
||||
const source = fs.readFileSync(entrypointPath, 'utf8');
|
||||
|
||||
it('exists and is copied into the image by the Dockerfile', () => {
|
||||
expect(fs.existsSync(entrypointPath)).toBe(true);
|
||||
expect(dockerfile).toContain('COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint');
|
||||
});
|
||||
|
||||
it('has LF line endings (a CRLF shebang kills the container with "no such file or directory")', () => {
|
||||
expect(source).not.toContain('\r');
|
||||
});
|
||||
|
||||
it('keeps the volume-over-/app preflight wording the wiki quotes', () => {
|
||||
expect(source).toContain('FATAL: TREK application files are missing from the image.');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ Set at minimum `ENCRYPTION_KEY`, `TZ`, and `ALLOWED_ORIGINS` in the **Environmen
|
||||
|
||||
Every other variable ships commented out, so setting it here does nothing. To use one — `APP_URL`, for example, which OIDC needs and which email notification links are built from — uncomment its line in the **Web editor** and put the value there directly, or change it to `- APP_URL=${APP_URL:-}` so the stack variable is picked up.
|
||||
|
||||
> Change a variable later by editing the **stack** and redeploying it — never by editing the container in place. Portainer's container editor rewrites the container's start-up command as well, which leaves it unable to boot; see [Troubleshooting](Troubleshooting#container-wont-start-syntax-error-end-of-file-unexpected-expecting-fi).
|
||||
|
||||
Generate an encryption key with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -197,6 +197,30 @@ docker compose up -d
|
||||
|
||||
---
|
||||
|
||||
## Container won't start: `Syntax error: end of file unexpected (expecting "fi")`
|
||||
|
||||
**Symptoms:** The container restarts in a loop and the log holds a single line, with no TREK banner and no Node error:
|
||||
|
||||
```
|
||||
TREK: 1: Syntax error: end of file unexpected (expecting "fi")
|
||||
```
|
||||
|
||||
It typically shows up right after you changed an environment variable — `COOKIE_SECURE=false`, for example — on a container that had been running fine.
|
||||
|
||||
**Cause:** The environment variable is innocent. The message comes from `/bin/sh` inside the container, before Node is ever reached. Some container management UIs — Portainer's **Duplicate/Edit** form among them — let you edit a running container's command by rendering it back into a text field and re-splitting that text when you submit. Older images shipped their start-up logic as a single quoted shell command, and the quotes do not survive that round-trip: the command comes back truncated, and the shell refuses to parse the half of an `if` block that is left. `TREK` is simply the word the re-split happened to strand as the shell's program name, from the message quoted in [**"Cannot find module" on startup**](#cannot-find-module-on-startup).
|
||||
|
||||
Once a container is in this state it stays broken across restarts and image pulls, because the mangled command is stored in the container's own configuration, not in the image.
|
||||
|
||||
**Fix:** Clear the command override so the image's own start-up command applies again.
|
||||
|
||||
In Portainer, open the container → **Duplicate/Edit** → **Command & logging**, empty the **Command** field completely, then deploy. An empty field means "use the image's command". Recreating the container from the image, or redeploying it as a stack, has the same effect.
|
||||
|
||||
> **Note:** `COOKIE_SECURE=false` is still the right setting if you reach TREK over plain HTTP — without it the browser will not send the session cookie and you cannot log in. Set it, just not by editing the container in place. Change environment variables by editing the **stack** and redeploying it; see [Install: Portainer](Install-Portainer).
|
||||
|
||||
Current images run their start-up logic from a script file, and their command is a single word with nothing left for a UI to mangle.
|
||||
|
||||
---
|
||||
|
||||
## Encryption key regenerated on restart — stored secrets stop working
|
||||
|
||||
**Cause:** On every startup, TREK resolves its encryption key in this order: (1) `ENCRYPTION_KEY` env var, (2) `data/.encryption_key` file, (3) legacy `data/.jwt_secret` fallback, (4) auto-generate a fresh key. If neither the env var nor the `data/` volume is persisted — for example after recreating a container without a volume mount — a new random key is generated and all stored secrets (SMTP password, OIDC client secret, API keys, MFA TOTP seeds) become unrecoverable.
|
||||
|
||||
Reference in New Issue
Block a user