feat(docker): add guided install and saved one-command updates
This commit is contained in:
1 parent
8dc187483c
commit
27447ce029
11 files changed
+395
-7
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user