Appending `,z` to a mount destination (`$DATA_DIR:/data,z`) makes docker mount a directory literally named `/data,z`. The app then writes its database into its own container layer instead of the host volume, `container_matches` never matches so every update recreates the container without its data, `backup_database` silently skips, and `resolveHostPath` refuses to provision computers (`/data/computers/1 isn't on a volume`). Build each spec as `source:destination[:options]` instead, with the `z` and `ro` options comma separated in the third field and the destination a plain absolute path. `backup_database` uses the same helper. `rescue_container_data` runs before a container whose /data is not the host's directory is replaced: it stops the app, copies `CONTAINER:/data` out with `docker cp`, moves a non-empty host directory aside and writes the copy in, so repairing an install from the broken form does not lose the database, the media or the computers' home directories. `install.test.ts` lifts the marked `selinux-detection` and `mount-specs` blocks out of install.sh, runs them in bash and parses the specs the way docker does, so the two forms can't drift back.
221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import path from "path";
|
|
|
|
const ROOT = import.meta.dir;
|
|
const INSTALL_SCRIPT = path.join(ROOT, "install.sh");
|
|
|
|
/**
|
|
* Lifts one marked block out of `install.sh`, so the tests run the real
|
|
* installer code instead of a copy of it that could drift.
|
|
*/
|
|
async function readMarkedBlock({ name }: { name: string }): Promise<string> {
|
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
|
const start = `# MARK:${name}\n`;
|
|
const end = `# END:${name}`;
|
|
const from = source.indexOf(start);
|
|
const to = source.indexOf(end);
|
|
|
|
expect(from).toBeGreaterThan(-1);
|
|
expect(to).toBeGreaterThan(from);
|
|
|
|
return source.slice(from + start.length, to);
|
|
}
|
|
|
|
/** Runs a bash snippet and returns its stdout, failing on any stderr. */
|
|
async function runBash({ script }: { script: string }): Promise<string> {
|
|
const proc = Bun.spawn({
|
|
cmd: ["bash", "-c", script],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, code] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
|
|
expect(stderr).toBe("");
|
|
expect(code).toBe(0);
|
|
|
|
return stdout.trim();
|
|
}
|
|
|
|
type MountSpecs = {
|
|
env: string;
|
|
data: string;
|
|
backup: string;
|
|
};
|
|
|
|
/** The three mount specs the installer builds for one SELinux state. */
|
|
async function runMountSpecs({
|
|
selinux,
|
|
}: {
|
|
selinux: boolean;
|
|
}): Promise<MountSpecs> {
|
|
const block = await readMarkedBlock({ name: "mount-specs" });
|
|
|
|
const script = [
|
|
`ENV_FILE="/opt/cobble/.env"`,
|
|
`DATA_DIR="/opt/cobble/data"`,
|
|
`BACKUP_ARCHIVE_DIR_HOST="/opt/cobble/backups-archives"`,
|
|
`MOUNT_OPTS="${selinux ? "z" : ""}"`,
|
|
block,
|
|
`printf '%s\\n' "$ENV_MOUNT" "$DATA_MOUNT" "$BACKUP_ARCHIVE_MOUNT"`,
|
|
].join("\n");
|
|
|
|
const [env = "", data = "", backup = ""] = (
|
|
await runBash({ script })
|
|
).split("\n");
|
|
|
|
return { env, data, backup };
|
|
}
|
|
|
|
/**
|
|
* Splits a `source:destination[:options]` spec the way docker does, so a
|
|
* destination that accidentally swallowed an option is caught.
|
|
*/
|
|
function parseMountSpec({ spec }: { spec: string }) {
|
|
const [source = "", destination = "", ...rest] = spec.split(":");
|
|
|
|
return { source, destination, options: rest.join(":") };
|
|
}
|
|
|
|
describe("install.sh SELinux detection", () => {
|
|
test("asks for the mount option when SELinux is enforcing", async () => {
|
|
const block = await readMarkedBlock({
|
|
name: "selinux-detection",
|
|
});
|
|
|
|
// An enforcing `getenforce` answers before the /sys fallback, so
|
|
// this result does not depend on the machine running the test.
|
|
const script = [
|
|
`OS="Linux"`,
|
|
`getenforce() { printf '%s' "Enforcing"; }`,
|
|
block,
|
|
`printf '%s' "$MOUNT_OPTS"`,
|
|
].join("\n");
|
|
|
|
expect(await runBash({ script })).toBe("z");
|
|
});
|
|
|
|
test("asks for no mount option when SELinux is disabled", async () => {
|
|
const block = await readMarkedBlock({
|
|
name: "selinux-detection",
|
|
});
|
|
|
|
// Answering `Disabled` makes the block fall through to the
|
|
// /sys/fs/selinux/enforce probe. Shadowing the path check keeps the
|
|
// result the same on an SELinux host and on a plain one.
|
|
const script = [
|
|
`OS="Linux"`,
|
|
`getenforce() { printf '%s' "Disabled"; }`,
|
|
`SELINUX_ENFORCE_FILE="/nonexistent/cobble-test"`,
|
|
block,
|
|
`printf '%s' "$MOUNT_OPTS"`,
|
|
].join("\n");
|
|
|
|
expect(await runBash({ script })).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("install.sh mount specs", () => {
|
|
for (const selinux of [false, true]) {
|
|
const label = selinux ? "with SELinux" : "without SELinux";
|
|
|
|
test(`mounts the data directory at /data ${label}`, async () => {
|
|
const { data } = await runMountSpecs({ selinux });
|
|
const { source, destination, options } = parseMountSpec({
|
|
spec: data,
|
|
});
|
|
|
|
// The regression: the SELinux option must be its own field.
|
|
// Gluing it onto the destination makes docker mount a
|
|
// directory named "/data,z", so the app keeps its database in
|
|
// its own container layer, every update wipes the install and
|
|
// computer provisioning fails.
|
|
expect(source).toBe("/opt/cobble/data");
|
|
expect(destination).toBe("/data");
|
|
expect(options).toBe(selinux ? "z" : "");
|
|
});
|
|
|
|
test(`mounts the backup archive directory ${label}`, async () => {
|
|
const { backup } = await runMountSpecs({ selinux });
|
|
const { source, destination, options } = parseMountSpec({
|
|
spec: backup,
|
|
});
|
|
|
|
expect(source).toBe("/opt/cobble/backups-archives");
|
|
expect(destination).toBe("/backups-archives");
|
|
expect(options).toBe(selinux ? "z" : "");
|
|
});
|
|
|
|
test(`mounts .env read-only ${label}`, async () => {
|
|
const { env } = await runMountSpecs({ selinux });
|
|
const { source, destination, options } = parseMountSpec({
|
|
spec: env,
|
|
});
|
|
|
|
expect(source).toBe("/opt/cobble/.env");
|
|
expect(destination).toBe("/app/.env");
|
|
// Docker splits options on commas, never on colons: `ro:z` is
|
|
// an unknown option, and the container refuses to start.
|
|
expect(options).toBe(selinux ? "ro,z" : "ro");
|
|
});
|
|
|
|
test(`keeps every option in its own field ${label}`, async () => {
|
|
const specs = await runMountSpecs({ selinux });
|
|
|
|
for (const spec of Object.values(specs)) {
|
|
const { destination, options } = parseMountSpec({ spec });
|
|
|
|
expect(destination).not.toContain(",");
|
|
expect(destination).not.toContain(":");
|
|
expect(destination.startsWith("/")).toBe(true);
|
|
|
|
// Only the options docker understands, comma separated.
|
|
for (const option of options.split(",")) {
|
|
expect(["", "ro", "rw", "z", "Z"]).toContain(option);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("install.sh data rescue", () => {
|
|
test("rescues the container's data before replacing it", async () => {
|
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
|
const rescue = source.indexOf("\n rescue_container_data\n");
|
|
const replace = source.indexOf(
|
|
`say "Replacing the Cobble container"`,
|
|
);
|
|
|
|
// A container whose /data was never mounted keeps its database in
|
|
// its own layer, so the copy has to happen before the container is
|
|
// removed.
|
|
expect(rescue).toBeGreaterThan(-1);
|
|
expect(replace).toBeGreaterThan(rescue);
|
|
});
|
|
|
|
test("the database backup uses the same mount options", async () => {
|
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
|
|
|
expect(source).toContain(`backup_mount="$data_source:/data`);
|
|
expect(source).toContain("${MOUNT_OPTS:+:$MOUNT_OPTS}");
|
|
});
|
|
|
|
test("no code line glues an option onto a destination", async () => {
|
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
|
|
|
// Comments may quote the old broken form; only real code counts.
|
|
const code = source
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("#"))
|
|
.join("\n");
|
|
|
|
expect(code).not.toContain("/data,z");
|
|
expect(code).not.toContain("/app/.env:ro,z");
|
|
expect(code).not.toContain("/backups-archives,z");
|
|
});
|
|
});
|