diff --git a/docs/native-mariadb-runtime-service.md b/docs/native-mariadb-runtime-service.md index bcb5378e..3d944c69 100644 --- a/docs/native-mariadb-runtime-service.md +++ b/docs/native-mariadb-runtime-service.md @@ -7,14 +7,14 @@ - The provider rejects UID 0 and resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. - Default executable discovery ignores the caller's `PATH`, searches fixed system directories, resolves symlinks, and requires every executable and ancestor to be UID-0-owned and not group/other writable. Child processes receive a fixed minimal `PATH`. - Every run gets a mode-`0700` `mkdtemp` root. MariaDB's data directory, temporary directory, socket, PID file, error log, plugin directory, secure-file directory, home, and working directory are all inside the bounded image. -- Initialization, daemon, and administrative clients run through fixed hard rlimits; each database command itself begins with `--no-defaults`. No shell or default socket is used. +- Initialization and the daemon run as the verified unprivileged FUSE mount owner through fixed hard rlimits; administrative clients retain the provider identity and each database command itself begins with `--no-defaults`. No shell or default socket is used. - Administration uses only the private Unix socket. Workloads receive a generated least-privilege `runtime` account over loopback TCP through the existing ephemeral connector-secret channel. - Initialization, FUSE, and daemon commands each run in a new owned process group. Cleanup addresses the retained group, not a PID file; it waits for the complete group to disappear after graceful shutdown, then applies group-wide `SIGTERM` and `SIGKILL` as needed. Linux captures the leader start-time token and revalidates it while the leader is alive. Root device/inode identity and a symlink-free tree are revalidated before recursive removal. - Failures, aborts, timeouts, startup crashes, and partial initialization all enter the same graceful-shutdown, forced-shutdown, wait, and verified-removal state machine. Cleanup failure is terminal and retained in bounded lifecycle evidence. - Address space is capped at 2 GiB, CPU at 300 seconds, individual daemon files at 128 MiB, open files at 512, and processes/threads at 512. Core files and locked memory are disabled. The datadir is a provider-owned 256 MiB ext4 image formatted with 4,096 inodes and mounted through unprivileged FUSE; device, byte, and inode geometry must be proven before initialization. Hosts without this containment fail closed. - Recipes may declare at most two native services, bounding the aggregate native ceiling to two 2-GiB address spaces, two 256-MiB images, and the corresponding process/file limits. - The daemon uses an empty bounded plugin directory and a bounded `secure-file-priv` directory. Startup fails unless every enabled storage engine is on the fixed local-only allowlist; FEDERATED, CONNECT, SPIDER, S3, and unknown enabled engines are rejected. The runtime account has privileges only on its generated database and cannot install plugins. -- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a real create, mount, geometry, write, unmount, process-group-exit, and removal probe succeed; unavailable reasons are stable codes without private paths. +- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, teardown, process-group exit, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. - Cleanup is single-flight for concurrent callers. A failed attempt may be retried, and evidence transitions from `teardown: failed` to a consistent released/completed state only after the retry proves cleanup. - Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths. diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index ac4157de..9533dac0 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto" import { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, statfs, writeFile } from "node:fs/promises" import { constants as fsConstants } from "node:fs" import { createConnection, createServer } from "node:net" -import { tmpdir } from "node:os" +import { tmpdir, userInfo } from "node:os" import { dirname, join, resolve } from "node:path" import type { RuntimePolicy, WorkspaceRecipeExternalServiceBoundary, WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" @@ -418,7 +418,9 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic const storageRoot = ownedNativePath(root, "storage") await mkdir(storageRoot, { mode: 0o700 }) const storageEnvironment = nativeMariaDbEnvironment(root.path) - const userArgument: string[] = [] + // MariaDB otherwise selects its package service account, which cannot write + // through a FUSE filesystem owned by the unprivileged provider caller. + const userArgument = [`--user=${nativeMariaDbCallerUser()}`] storage = await provisionNativeMariaDbStorage(binaries, root, storageRoot, dependencies, storageEnvironment, signal) const datadir = join(storageRoot, "database") const runtimeDirectory = join(storageRoot, "runtime") @@ -545,36 +547,35 @@ export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDep } catch { return { status: "unavailable", reason: "unprivileged-host-required" } } - let binaries: NativeMariaDbBinaries try { - binaries = await resolveNativeMariaDbBinaries(dependencies) + await resolveNativeMariaDbBinaries(dependencies) } catch { return { status: "unavailable", reason: "trusted-containment-tools-unavailable" } } - let root: OwnedNativeRoot | undefined - let storage: NativeMariaDbStorage | undefined try { - root = await createOwnedNativeRoot() - const mountpoint = ownedNativePath(root, "storage") - await mkdir(mountpoint, { mode: 0o700 }) - storage = await provisionNativeMariaDbStorage(binaries, root, mountpoint, dependencies, nativeMariaDbEnvironment(root.path)) - await writeFile(join(mountpoint, ".readiness"), "ready", { mode: 0o600 }) - await stopNativeMariaDbStorage(storage, binaries, dependencies, root) - storage = undefined - await removeOwnedNativeRoot(root, dependencies) - root = undefined + const evidence: RuntimeServiceEvidence[] = [] + const managed = await provisionMysqlNativeService({ + id: "native-mariadb-readiness", + kind: "mysql", + configuration: { provider: "native", engine: "mariadb" }, + outputs: {}, + }, dependencies, { externalServices: [], externalServiceWritesApproved: false }, evidence) + await managed.release() return { status: "ready" } - } catch { - try { - if (storage && root) await stopNativeMariaDbStorage(storage, binaries, dependencies, root) - if (root) await removeOwnedNativeRoot(root, dependencies) - } catch { + } catch (error) { + if (runtimeServiceEvidenceFromError(error)?.some((entry) => entry.teardown === "failed")) { return { status: "unavailable", reason: "containment-probe-cleanup-failed" } } return { status: "unavailable", reason: "bounded-filesystem-unavailable" } } } +function nativeMariaDbCallerUser(): string { + const user = userInfo().username + if (!user || user.includes("\0")) throw new Error("Native MariaDB caller identity cannot be proven") + return user +} + function assertNativeMariaDbConfiguration(service: WorkspaceRecipeRuntimeService): void { const configuration = service.configuration if (configuration?.provider !== "native" || configuration.engine !== "mariadb") throw new Error("Native MySQL-compatible services require engine=mariadb") diff --git a/tests/native-mariadb-runtime-service.test.ts b/tests/native-mariadb-runtime-service.test.ts index 75a00cfa..a3a3b29b 100644 --- a/tests/native-mariadb-runtime-service.test.ts +++ b/tests/native-mariadb-runtime-service.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { chmod, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" import { createServer } from "node:net" -import { tmpdir } from "node:os" +import { tmpdir, userInfo } from "node:os" import { basename, dirname, join } from "node:path" import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" @@ -100,6 +100,8 @@ try { } assert.deepEqual(await nativeMariaDbHostReadiness(dependencies), { status: "ready" }) + assert.ok(calls.some((call) => call.stdin?.includes("CREATE DATABASE")), "readiness provisions a disposable database instead of only writing a mount marker") + assert.ok(calls.some((call) => call.stdin === "SHOW ENGINES;\n"), "readiness proves the daemon storage-engine policy") assert.equal(calls.every((call) => call.env?.PATH === "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), true, "native commands ignore the caller PATH") const before = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) @@ -121,6 +123,7 @@ try { const initializerArgs = (await readFile(join(dirname(datadir), "tmp", "initializer-args"), "utf8")).trim().split("\n") assert.ok(initializerArgs.includes("--no-defaults")) assert.ok(initializerArgs.some((arg) => arg === `--datadir=${datadir}`)) + assert.ok(initializerArgs.includes(`--user=${userInfo().username}`), "the initializer runs as the unprivileged FUSE mount owner") assert.ok(calls.some((call) => call.args.some((arg) => arg.endsWith("truncate")) && call.args.includes("268435456")), "the provider creates a fixed 256 MiB backing image") assert.ok(calls.some((call) => call.args.some((arg) => arg.endsWith("mkfs.ext4")) && call.args.includes("-N") && call.args.includes("4096")), "the provider creates a fixed 4096-inode filesystem") assert.ok(daemonArgs.includes("--as=2147483648")) @@ -130,6 +133,7 @@ try { assert.ok(daemonArgs.includes("--nproc=512")) assert.ok(daemonArgs.includes(`--plugin-dir=${join(dirname(datadir), "plugins")}`)) assert.ok(daemonArgs.includes(`--secure-file-priv=${join(dirname(datadir), "files")}`)) + assert.ok(daemonArgs.includes(`--user=${userInfo().username}`), "the daemon runs as the unprivileged FUSE mount owner") assert.ok(daemonArgs.every((arg) => !arg.startsWith("--socket=") || arg.startsWith(`--socket=${dirname(datadir)}/runtime/`))) assert.equal(daemonArgs.includes(password), false) const createUser = calls.find((call) => call.stdin?.includes("CREATE USER"))