Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/native-mariadb-runtime-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 21 additions & 20 deletions packages/cli/src/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion tests/native-mariadb-runtime-service.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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-")))
Expand All @@ -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"))
Expand All @@ -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"))
Expand Down