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.
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user