Files
cobble/install.test.ts
tben 05af559adb Keep SELinux mount options out of the mount destination
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.
2026-10-22 16:43:10 +01:00

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");
});
});