Skip to content

Commit 062ff5c

Browse files
authored
feat(runtime-services): add native ephemeral MariaDB provider (#2062)
* feat(runtime-services): add native ephemeral MariaDB provider * fix(runtime-services): harden native MariaDB containment * fix(runtime-services): close native MariaDB isolation gaps
1 parent ff3f782 commit 062ff5c

18 files changed

Lines changed: 1333 additions & 27 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Native MariaDB Runtime Service
2+
3+
`provider: "native"` provisions a short-lived MariaDB process for MySQL-compatible workloads on Docker-free hosts. The generic runtime-service layer owns its complete lifecycle. Recipes may select only `provider: "native"` and `engine: "mariadb"`; host, port, credentials, sockets, configuration files, and filesystem paths are not configurable.
4+
5+
## Isolation Contract
6+
7+
- 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.
8+
- 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`.
9+
- 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.
10+
- 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.
11+
- 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.
12+
- 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.
13+
- 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.
14+
- 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.
15+
- 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.
16+
- 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.
17+
- 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.
18+
- 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.
19+
- Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths.
20+
21+
## Daemon Arguments
22+
23+
The provider starts `mariadbd` through `prlimit` with these fixed controls plus provider-owned absolute paths and a freshly allocated loopback port:
24+
25+
```text
26+
--no-defaults
27+
--datadir=<bounded-image>/database
28+
--socket=<bounded-image>/runtime/server.sock
29+
--pid-file=<bounded-image>/runtime/server.pid
30+
--log-error=<bounded-image>/runtime/server.log
31+
--tmpdir=<bounded-image>/tmp
32+
--plugin-dir=<bounded-image>/plugins
33+
--secure-file-priv=<bounded-image>/files
34+
--bind-address=127.0.0.1
35+
--port=<ephemeral>
36+
--skip-name-resolve
37+
--skip-log-bin
38+
--skip-host-cache
39+
--skip-slave-start
40+
--skip-symbolic-links
41+
--local-infile=OFF
42+
--performance-schema=OFF
43+
--skip-feedback
44+
--innodb-buffer-pool-size=32M
45+
--innodb-buffer-pool-size-max=32M
46+
--innodb-log-buffer-size=4M
47+
--key-buffer-size=8M
48+
--aria-pagecache-buffer-size=8M
49+
--thread-handling=pool-of-threads
50+
--thread-pool-size=1
51+
--aria-log-file-size=16M
52+
--innodb-file-per-table=OFF
53+
--innodb-data-file-path=ibdata1:32M:autoextend:max:96M
54+
--innodb-temp-data-file-path=ibtmp1:16M:autoextend:max:32M
55+
--innodb-log-file-size=32M
56+
--max-connections=10
57+
--max-prepared-stmt-count=256
58+
--max-session-mem-used=32M
59+
--open-files-limit=512
60+
--thread-cache-size=0
61+
--table-open-cache=128
62+
--table-definition-cache=128
63+
--tmp-table-size=8M
64+
--max-heap-table-size=8M
65+
```
66+
67+
The hard address-space ceiling is 2 GiB. Linux runs record the owned daemon's post-readiness RSS in evidence; the MariaDB 10.11 integration gate additionally requires observed RSS to remain at or below 128 MiB.
68+
69+
## Integration Contract
70+
71+
Homeboy Extensions issue #2412 may select this provider by emitting the generic runtime-service recipe declaration below. WP Codebox performs all provisioning and cleanup; callers do not supply or manage native process details.
72+
73+
```json
74+
{
75+
"id": "wordpress-database",
76+
"kind": "mysql",
77+
"configuration": { "provider": "native", "engine": "mariadb" },
78+
"outputs": {
79+
"host": "DB_HOST",
80+
"port": "DB_PORT",
81+
"username": "DB_USER",
82+
"password": "DB_PASSWORD",
83+
"database": "DB_NAME"
84+
}
85+
}
86+
```

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,9 @@
250250
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
251251
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
252252
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
253-
"test:runtime-services": "tsx tests/runtime-services.test.ts && tsx tests/external-mysql-runtime-service.test.ts",
253+
"test:runtime-services": "tsx tests/runtime-services.test.ts && tsx tests/external-mysql-runtime-service.test.ts && tsx tests/native-mariadb-runtime-service.test.ts",
254254
"test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts",
255+
"test:native-mariadb-runtime-service-integration": "tsx tests/native-mariadb-runtime-service.integration.test.ts",
255256
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
256257
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",
257258
"test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts",

packages/cli/src/commands/discovery.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createWorkspaceRecipeJsonSchema, runtimeDescriptor, type RuntimeDescrip
22
import { commandRegistry, type CommandDefinition } from "@automattic/wp-codebox-core/contracts"
33
import { printCommandCatalogHumanOutput, printRecipeSchemaHumanOutput, printRuntimeDescriptorHumanOutput } from "../output.js"
44
import { cliRuntimeBackendRecipePolicy, listCliRecipeCommandDefinitions, listCliRuntimeBackendKinds } from "../runtime-backends.js"
5+
import { nativeMariaDbHostReadiness } from "../runtime-services.js"
56

67
interface CommandCatalogOutput {
78
schema: "wp-codebox/command-catalog/v1"
@@ -40,7 +41,7 @@ export async function runRecipeSchemaCommand(args: string[]): Promise<number> {
4041

4142
export async function runRuntimeDescriptorCommand(args: string[]): Promise<number> {
4243
const json = parseDiscoveryJsonOption(args)
43-
const output = runtimeDescriptorOutput()
44+
const output = await runtimeDescriptorOutput()
4445
if (!json) {
4546
printRuntimeDescriptorHumanOutput(output)
4647
return 0
@@ -109,6 +110,6 @@ function recipeSchemaOutput(): RecipeSchemaOutput {
109110
}
110111
}
111112

112-
function runtimeDescriptorOutput(): RuntimeDescriptor {
113-
return runtimeDescriptor()
113+
async function runtimeDescriptorOutput(): Promise<RuntimeDescriptor> {
114+
return runtimeDescriptor({ nativeMariaDb: await nativeMariaDbHostReadiness() })
114115
}

packages/cli/src/commands/recipe-run-artifact-pointers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ArtifactBundle, RecipeRunSummary, RuntimeInfo } from "@automattic/
44
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
55
import type { RunOutput } from "../runtime-command-wrappers.js"
66
import type { RecipeArtifactPointerCommandStatus, RecipeArtifactPointerState, RecipeBrowserEvidence, RecipeDiagnosticArtifactRef, RecipePhaseEvidence } from "./recipe-run-types.js"
7+
import type { RuntimeServiceEvidence } from "../runtime-services.js"
78

89
export class RecipeArtifactPointerTracker {
910
private command: string | undefined
@@ -15,6 +16,7 @@ export class RecipeArtifactPointerTracker {
1516
private browserEvidence: RecipeBrowserEvidence[] = []
1617
private diagnosticArtifacts: RecipeDiagnosticArtifactRef[] = []
1718
private result: RecipeRunSummary | undefined
19+
private managedRuntimeServices: RuntimeServiceEvidence[] = []
1820

1921
constructor(private readonly directory: string | undefined, private readonly runId: string, private readonly recipePath: string, private readonly startedAt: string) {}
2022

@@ -32,6 +34,7 @@ export class RecipeArtifactPointerTracker {
3234
this.browserEvidence = state.browserEvidence ?? this.browserEvidence
3335
this.diagnosticArtifacts = state.diagnosticArtifacts ?? this.diagnosticArtifacts
3436
this.result = state.result ?? this.result
37+
this.managedRuntimeServices = state.managedRuntimeServices ?? this.managedRuntimeServices
3538

3639
const pointer = stripUndefined({
3740
schema: "wp-codebox/recipe-run-artifact-pointer/v1",
@@ -47,6 +50,7 @@ export class RecipeArtifactPointerTracker {
4750
failure: this.failure,
4851
failurePhase: recipeArtifactPointerFailurePhase(this.failure, this.phases),
4952
result: this.result,
53+
managedRuntimeServices: this.managedRuntimeServices.length > 0 ? this.managedRuntimeServices : undefined,
5054
browserEvidence: this.browserEvidence.length > 0 ? this.browserEvidence : undefined,
5155
diagnosticArtifacts: this.diagnosticArtifacts.length > 0 ? this.diagnosticArtifacts : undefined,
5256
...await recipeArtifactPointerArtifactState(this.directory, this.runtime, this.artifacts),

packages/cli/src/commands/recipe-run-finalizer.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ interface RecipeRunCommonOutputFields {
6767
replayStatus?: RecipeRunOutput["replayStatus"]
6868
fuzzRun?: RecipeRunOutput["fuzzRun"]
6969
provenance?: RecipeRunOutput["provenance"]
70+
managedRuntimeServices?: RecipeRunOutput["managedRuntimeServices"]
7071
artifacts?: ArtifactBundle
7172
interruption?: RecipeRunOutput["interruption"]
7273
}
@@ -194,7 +195,7 @@ export async function finalizeCompletedRecipeRun(args: FinalizeCompletedRecipeRu
194195
}) as RecipeRunOutput)
195196
runRecord = await args.runRegistry.update(args.runRecord.runId, { result: runtimeRunResultFromRecipeSummary(output.result!) })
196197
output.run = runRecord
197-
await args.artifactPointer.update({ commandStatus: args.success ? "completed" : "failed", runtime: args.runtime, artifacts: args.artifacts, failure: args.failure, phases: args.phaseEvidence, stepFailures: args.output.stepFailures, browserEvidence: args.browserEvidence, result: output.result })
198+
await args.artifactPointer.update({ commandStatus: args.success ? "completed" : "failed", runtime: args.runtime, artifacts: args.artifacts, failure: args.failure, phases: args.phaseEvidence, stepFailures: args.output.stepFailures, browserEvidence: args.browserEvidence, managedRuntimeServices: args.output.managedRuntimeServices, result: output.result })
198199
return output
199200
}
200201

@@ -220,19 +221,19 @@ export async function finalizeRecoveredRecipeFailure(args: FinalizeRecoveredReci
220221
}) as RecipeRunOutput)
221222
runRecord = await args.runRegistry.update(args.runRecord.runId, { result: runtimeRunResultFromRecipeSummary(output.result!) })
222223
output.run = runRecord
223-
await args.artifactPointer.update({ commandStatus: "failed", ...(args.runtime ? { runtime: args.runtime } : {}), ...(args.artifacts ? { artifacts: args.artifacts } : {}), failure: args.serializedError, phases: args.phaseEvidence, stepFailures: args.output.stepFailures, browserEvidence: args.browserEvidence, diagnosticArtifacts: args.diagnosticArtifacts, result: output.result })
224+
await args.artifactPointer.update({ commandStatus: "failed", ...(args.runtime ? { runtime: args.runtime } : {}), ...(args.artifacts ? { artifacts: args.artifacts } : {}), failure: args.serializedError, phases: args.phaseEvidence, stepFailures: args.output.stepFailures, browserEvidence: args.browserEvidence, diagnosticArtifacts: args.diagnosticArtifacts, managedRuntimeServices: args.output.managedRuntimeServices, result: output.result })
224225
return output
225226
}
226227

227-
export async function runRecipeCleanup(runRegistry: RuntimeRunRegistry, runRecord: RuntimeRunRecord, cleanup: () => Promise<void>): Promise<RunResourceCleanupEvidence> {
228+
export async function runRecipeCleanup(runRegistry: RuntimeRunRegistry, runRecord: RuntimeRunRecord, cleanup: () => Promise<void>, finalMetadata?: () => Record<string, unknown>): Promise<RunResourceCleanupEvidence> {
228229
const startedAtMs = Date.now()
229230
await runRegistry.update(runRecord.runId, { cleanup: { status: "running" } })
230231
try {
231232
await cleanup()
232-
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "succeeded" } })
233+
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "succeeded" }, ...(finalMetadata ? { metadata: finalMetadata() } : {}) })
233234
return cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs)
234235
} catch (error) {
235-
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "failed", error: serializeError(error) } })
236+
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "failed", error: serializeError(error) }, ...(finalMetadata ? { metadata: finalMetadata() } : {}) })
236237
const cleanupError = serializeRecipeRunError(error)
237238
throw new RunResourceCleanupError(
238239
cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs, cleanupError),

packages/cli/src/commands/recipe-run-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RecipeExternalServiceBoundaryHostCorrelation } from "../recipe-ext
55
import type { RecipeValidationIssue, RecipeWorkflowPhase } from "../recipe-validation.js"
66
import type { RunOutput } from "../runtime-command-wrappers.js"
77
import type { RecipeAdversarialCampaignOutput } from "../adversarial-recipe.js"
8+
import type { RuntimeServiceEvidence } from "../runtime-services.js"
89

910
export interface RecipeRunOptions {
1011
recipePath: string
@@ -83,6 +84,7 @@ export interface RecipeRunOutput {
8384
fuzzRun?: RecipeFuzzRunResult
8485
adversarialCampaigns?: RecipeAdversarialCampaignOutput[]
8586
provenance?: RecipeRunProvenance
87+
managedRuntimeServices?: RuntimeServiceEvidence[]
8688
result?: RecipeRunSummary
8789
artifacts?: ArtifactBundle
8890
run?: RuntimeRunRecord
@@ -263,6 +265,7 @@ export interface RecipeArtifactPointerState {
263265
browserEvidence?: RecipeBrowserEvidence[]
264266
diagnosticArtifacts?: RecipeDiagnosticArtifactRef[]
265267
result?: RecipeRunSummary
268+
managedRuntimeServices?: RuntimeServiceEvidence[]
266269
}
267270

268271
export interface RecipeEffectiveRecipeArtifact {

packages/cli/src/commands/recipe-run.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
445445
browserEvidence,
446446
replayStatus: evidence.replayStatus ? recipeReplayStatusOutput(evidence.replayStatus) : undefined,
447447
failure: recipeFailure,
448-
output: { ...completedRecipeOutputFields({ executions, componentContracts: componentContractResults(recipe, extraPlugins, phaseTracker.list(), executions), stagedFiles: stagedFiles.map(recipeRunStagedFile), fixtureDatabases, siteSeeds, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts, stepFailures, phaseEvidence: phaseTracker.list(), advisoryFailures, browserEvidence, benchResultsList, fuzzRun: fuzzRunResult, evidence }), ...(adversarialCampaigns.length > 0 ? { adversarialCampaigns } : {}), provenance: recipeRunProvenance(recipe, recipePath) },
448+
output: { ...completedRecipeOutputFields({ executions, componentContracts: componentContractResults(recipe, extraPlugins, phaseTracker.list(), executions), stagedFiles: stagedFiles.map(recipeRunStagedFile), fixtureDatabases, siteSeeds, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts, stepFailures, phaseEvidence: phaseTracker.list(), advisoryFailures, browserEvidence, benchResultsList, fuzzRun: fuzzRunResult, evidence }), ...(adversarialCampaigns.length > 0 ? { adversarialCampaigns } : {}), provenance: recipeRunProvenance(recipe, recipePath), managedRuntimeServices: serviceEvidence },
449449
})
450450
}
451451

@@ -464,7 +464,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
464464
phaseEvidence: phaseTracker.list(),
465465
browserEvidence,
466466
replayStatus: evidence.replayStatus ? recipeReplayStatusOutput(evidence.replayStatus) : undefined,
467-
output: { ...completedRecipeOutputFields({ executions, componentContracts: componentContractResults(recipe, extraPlugins, phaseTracker.list(), executions), stagedFiles: stagedFiles.map(recipeRunStagedFile), fixtureDatabases, siteSeeds, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts, stepFailures, phaseEvidence: phaseTracker.list(), advisoryFailures, browserEvidence, benchResultsList, fuzzRun: fuzzRunResult, evidence }), ...(adversarialCampaigns.length > 0 ? { adversarialCampaigns } : {}), provenance: recipeRunProvenance(recipe, recipePath) },
467+
output: { ...completedRecipeOutputFields({ executions, componentContracts: componentContractResults(recipe, extraPlugins, phaseTracker.list(), executions), stagedFiles: stagedFiles.map(recipeRunStagedFile), fixtureDatabases, siteSeeds, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts, stepFailures, phaseEvidence: phaseTracker.list(), advisoryFailures, browserEvidence, benchResultsList, fuzzRun: fuzzRunResult, evidence }), ...(adversarialCampaigns.length > 0 ? { adversarialCampaigns } : {}), provenance: recipeRunProvenance(recipe, recipePath), managedRuntimeServices: serviceEvidence },
468468
})
469469
} catch (error) {
470470
const failedServiceEvidence = runtimeServiceEvidenceFromError(error)
@@ -592,6 +592,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
592592
...(adversarialCampaigns.length > 0 ? { adversarialCampaigns } : {}),
593593
diagnostics: recipeRuntimeDiagnostics(recipe, executions, error),
594594
provenance: recipeRunProvenance(recipe, recipePath, diagnosticArtifacts),
595+
managedRuntimeServices: serviceEvidence,
595596
},
596597
})
597598
} finally {
@@ -611,16 +612,12 @@ export async function runManagedServiceCleanup(
611612
cleanup: () => Promise<void>,
612613
): Promise<RunResourceCleanupEvidence> {
613614
try {
614-
return await runRecipeCleanup(runRegistry, runRecord, cleanup)
615+
return await runRecipeCleanup(runRegistry, runRecord, cleanup, () => ({ managedRuntimeServices: serviceEvidence }))
615616
} catch (error) {
616617
if (preservePrimaryFailure && error instanceof RunResourceCleanupError) {
617618
return error.evidence
618619
}
619620
throw error
620-
} finally {
621-
await runRegistry.update(runRecord.runId, {
622-
metadata: { managedRuntimeServices: serviceEvidence },
623-
})
624621
}
625622
}
626623

0 commit comments

Comments
 (0)