feat(docker): add guided install and saved one-command updates
CI / check (push) Successful in 53s
CI / deploy (push) Successful in 1m9s
CI / publish-container (push) Successful in 46s

This commit is contained in:
Simo committed 2026-09-09 20:49:05 +02:00
1 parent 8dc187483c
commit 27447ce029
11 files changed
+395 -7

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
import { spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const bash =
process.platform === "win32"
? ((process.env.PATH ?? "")
.split(delimiter)
.flatMap((dir) => [
join(dir, "bash.exe"),
join(dirname(dir), "bin", "bash.exe"),
join(dirname(dirname(dir)), "bin", "bash.exe"),
])
.find((path) => existsSync(path)) ?? "bash")
: "bash";
function configure(
input: string,
existing?: string,
profile?: string,
start = false,
) {
const dir = mkdtempSync(join(tmpdir(), "cms-install-test-"));
try {
mkdirSync(join(dir, "scripts"));
for (const name of ["docker-install.sh", "docker-config.sh"])
copyFileSync(resolve(root, "scripts", name), join(dir, "scripts", name));
writeFileSync(
join(dir, "scripts/docker-update.sh"),
"#!/usr/bin/env bash\nprintf 'Update invoked\\n'\n",
);
copyFileSync(
resolve(root, "docker-image.txt"),
join(dir, "docker-image.txt"),
);
if (existing !== undefined) writeFileSync(join(dir, ".env"), existing);
if (profile !== undefined)
writeFileSync(join(dir, ".docker-install"), profile);
const result = spawnSync(
bash,
[
join(dir, "scripts/docker-install.sh"),
...(start ? [] : ["--configure-only"]),
],
{
cwd: dir,
input,
encoding: "utf8",
timeout: 15000,
env: {
...process.env,
BASH_ENV: resolve(root, "src/test/docker-install-harness.sh"),
CMS_PUBLIC_URL: undefined,
CMS_IMAGE_REPOSITORY: undefined,
},
},
);
if (result.error) throw result.error;
return {
status: result.status,
output: result.stdout + result.stderr,
env: existsSync(join(dir, ".env"))
? readFileSync(join(dir, ".env"), "utf8")
: "",
profile: existsSync(join(dir, ".docker-install"))
? readFileSync(join(dir, ".docker-install"), "utf8")
: "",
injected: existsSync(join(dir, "injected")),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const answers = [
"https://hotel.example",
"prebuilt",
"Test Hotel",
"",
"",
"hotel",
"cms",
"p@ss'$ word",
"",
"https://imager.example/imaging",
"",
].join("\n");
describe("guided Docker installation", () => {
it("generates a unique secret and encodes database credentials without exposing them", () => {
const result = configure(answers);
expect(result.status, result.output).toBe(0);
expect(result.env).toContain(
"mysql://cms:p%40ss%27%24%[email protected]:3306/hotel",
);
const secret = result.env.match(/AUTH_SECRET='([a-f0-9]{64})'/)?.[1];
expect(secret).toHaveLength(64);
expect(result.output).not.toContain(secret);
expect(result.output).not.toContain("p@ss");
expect(result.profile).toBe(
"MODE=prebuilt\nPUBLIC_URL=https://hotel.example\n",
);
expect(result.output).toContain("No container was started");
});
it("preserves an existing environment byte for byte", () => {
const existing = "AUTH_SECRET=keep-me\nDATABASE_URL=existing\n";
const result = configure("https://hotel.example\nsource\n", existing);
expect(result.status, result.output).toBe(0);
expect(result.env).toBe(existing);
expect(result.profile).toContain("MODE=source");
});
it("does not create configuration when input is cancelled", () => {
const result = configure("https://hotel.example\nprebuilt\n");
expect(result.status).not.toBe(0);
expect(result.env).toBe("");
expect(result.profile).toBe("");
});
it("rejects unsafe dotenv values", () => {
const result = configure(answers.replace("Test Hotel", "Hotel '$BAD"));
expect(result.status).not.toBe(0);
expect(result.env).toBe("");
});
it("treats saved settings as data, never shell commands", () => {
const result = configure(
"",
"original",
"MODE=$(touch injected)\nPUBLIC_URL=https://hotel.example\n",
);
expect(result.status).not.toBe(0);
expect(result.env).toBe("original");
expect(result.injected).toBe(false);
});
});
it("hands a completed installation to the existing updater", () => {
const result = configure(answers, undefined, undefined, true);
expect(result.status, result.output).toBe(0);
expect(result.output).toContain("Update invoked");
expect(result.profile).toContain("MODE=prebuilt");
});
+37 -4
View File
@@ -30,6 +30,14 @@ function simulate(scenario: string) {
try {
mkdirSync(join(dir, "scripts"));
mkdirSync(join(dir, "logs"));
copyFileSync(
resolve(root, "scripts/docker-config.sh"),
join(dir, "scripts/docker-config.sh"),
);
copyFileSync(
resolve(root, "docker-image.txt"),
join(dir, "docker-image.txt"),
);
writeFileSync(
join(dir, "logs/docker-release-history.log"),
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
@@ -39,6 +47,11 @@ function simulate(scenario: string) {
join(dir, "scripts/docker-update.sh"),
);
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
if (scenario.startsWith("saved-"))
writeFileSync(
join(dir, ".docker-install"),
`MODE=${scenario === "saved-source" ? "source" : "prebuilt"}\nPUBLIC_URL=https://saved.test\n`,
);
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
cwd: dir,
encoding: "utf8",
@@ -49,10 +62,14 @@ function simulate(scenario: string) {
TEST_DIR: dir.replaceAll("\\", "/"),
TEST_SHA: sha,
SCENARIO: scenario,
CMS_PUBLIC_URL: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
CMS_PUBLIC_URL: scenario.startsWith("saved-")
? undefined
: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
? undefined
: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
},
});
if (result.error) throw result.error;
@@ -169,3 +186,19 @@ describe("HTTP release verification", () => {
expect(result.status, result.stderr).toBe(expected);
});
});
it("updates using the saved profile and repository-provided image without arguments", () => {
const r = simulate("saved-prebuilt");
expect(r.status, r.output).toBe(0);
expect(r.calls).toContain(
`docker pull gitlab.epicnabbo.nl/simo/epicnext-cms:${sha}`,
);
expect(r.calls).toContain("https://saved.test/api/health");
expect(r.calls).not.toContain("docker compose build");
});
it("keeps source builds available for a saved source installation", () => {
const r = simulate("saved-source");
expect(r.status, r.output).toBe(0);
expect(r.calls).toContain("docker compose build");
expect(r.calls).not.toContain("docker pull");
});
+10
View File
@@ -0,0 +1,10 @@
# No Docker, permissions or external services are modified by wizard tests.
docker() { return 0; }
uname() { echo Linux; }
flock() { :; }
export -f docker uname flock
install() { :; }
chown() { :; }
stat() { echo 33:33; }
sudo() { "$@"; }
export -f install chown stat sudo