ReviewOS

also looking at this

stacks/drone

feat: continue unresolved runtime update maintenance

#59
Merged chris wants to merge codex/runtime-maintenance-continuations into main
21 files +1097 -40

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+20-0
Changes to .github/workflows/ci.yml
@@ -434,6 +434,26 @@ jobs:
434434 - run: bun install --frozen-lockfile
435435 - run: cp .env.example .env
436436 - run: ./buddy key:generate
437 - name: Qualify actual ENOSPC and fresh runtime recovery in a private filesystem
438 shell: bash
439 run: |
440 set -euo pipefail
441 sudo --non-interactive env RUNTIME_HOST_ENOSPC_TEST=1 RUNTIME_HOST_ENOSPC_EVIDENCE="$RUNNER_TEMP/runtime-host-enospc" unshare --mount --propagation private --fork "$(command -v bun)" test ./tests/integration/runtime-host-storage.test.ts
442 - name: Make the owned filesystem campaign artifacts readable by the runner
443 if: always()
444 shell: bash
445 run: |
446 set -euo pipefail
447 if sudo --non-interactive test -d "$RUNNER_TEMP/runtime-host-enospc"; then
448 sudo --non-interactive chown -R --no-dereference "$(id -u):$(id -g)" "$RUNNER_TEMP/runtime-host-enospc"
449 fi
450 - uses: actions/upload-artifact@v4
451 if: always()
452 with:
453 name: runtime-host-enospc-evidence
454 if-no-files-found: ignore
455 retention-days: 7
456 path: ${{ runner.temp }}/runtime-host-enospc
437457 - name: Qualify accepted purge with real process and mount isolation
438458 run: sudo --non-interactive env PUBLIC_SAFETY_PURGE_NAMESPACE_TEST=1 unshare --fork --pid --mount-proc "$(command -v bun)" test ./tests/integration/public-safety-purge.test.ts
439459 firmware:
docs/adr/0031-full-host-filesystem-fixture.mdadded+40-0
Changes to docs/adr/0031-full-host-filesystem-fixture.md
@@ -0,0 +1,40 @@
1# Full host filesystem qualification
2
3Status: implementation in progress for #42. Physical deployment remains disabled.
4
5Extend the public update-coordinator storage campaign with actual Linux ENOSPC.
6Run explicitly inside a private mount namespace, verified against PID 1's mount
7namespace, with kernel mountinfo readback rejecting shared or slave propagation.
8Mount a new 16 MiB tmpfs only at an exclusively created private fixture
9directory. Read back the filesystem type, capacity, ownership and mount identity.
10No block device, existing mount or physical vehicle is involved.
11
12Keep signed repositories and the owned build outside the bounded filesystem.
13Place the actual protocol store, candidates and slots inside it. Confirm a real
14release-7 host session, prepare release 9, then publish new signed root/role
15metadata. At the public slot-selection progress boundary, fill an owned sibling
16file through real writes until ENOSPC and release only 1 MiB of its allocation.
17The larger signed image must make actual partial progress before ENOSPC. Leave
18the filesystem full until the trial process has exited, including its attempts
19to record failure. Never replace filesystem or SQLite methods with faults.
20
21Inspect retained bytes and public status after removing only the owned filler.
22Require the previous known-good image and accepted metadata/security floors to
23survive without a new health confirmation. A pending attempt and selected slot
24must undergo explicit fresh-process metadata recovery and exact-slot abandonment
25before an unrestricted fresh trial can confirm a different slot through actual
26new boot/session evidence. Preserve failed partial bytes and the older image.
27
28Copy and hash the full volume before freeing the filler or opening it for
29inspection, then archive later retained states before unmounting the volatile
30filesystem. Keep failure observations, status snapshots
31and process results. CI retains these synthetic artifacts for seven days.
32The opt-in Linux qualification must execute in CI; a skipped local test supplies
33no positive filesystem-exhaustion evidence.
34
35Linux documents tmpfs capacity limits and loss of contents on unmount in
36[tmpfs(5)](https://man7.org/linux/man-pages/man5/tmpfs.5.html). This is real logical
37filesystem exhaustion on a memory-backed filesystem. It does not qualify storage
38device exhaustion, persistent-media durability, EDQUOT, EIO, sync failures or
39electrical power loss. Those remain separate acceptance requirements. The fixture
40adds no production storage layer, privileged application command or dependency.
docs/roadmap/issues.jsonmodified+4-1
Changes to docs/roadmap/issues.json
@@ -291,7 +291,10 @@
291291 "tests/fixtures/updates/host-write-limit.ts",
292292 "tests/fixtures/updates/file-write-limit.cpp",
293293 "tests/unit/updates-host-writes.test.ts",
294 "docs/adr/0030-host-storage-fault-fixtures.md"
294 "docs/adr/0030-host-storage-fault-fixtures.md",
295 "tests/fixtures/updates/host-volume-full.ts",
296 "tests/integration/runtime-host-storage.test.ts",
297 "docs/adr/0031-full-host-filesystem-fixture.md"
295298 ],
296299 "remaining": [
297300 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
docs/runtime-updates.mdmodified+9-0
Changes to docs/runtime-updates.md
@@ -592,3 +592,12 @@ new slot can become known-good through actual fresh runtime health; the failed
592592partial file stays retained. CI keeps the synthetic campaign artifacts for seven
593593days. This file-size-limit evidence leaves zero-return writes, full-medium/quota,
594594EIO, sync and physical power-loss campaigns open.
595
596The opt-in [full-filesystem qualification](adr/0031-full-host-filesystem-fixture.md)
597uses a separate private Linux mount namespace and a new bounded tmpfs for the
598actual store, candidates and slots. It targets real ENOSPC during the image copy
599and SQLite failure-state persistence, then exercises explicit metadata recovery,
600exact-slot abandonment and a fresh host trial in new processes. Retained file
601archives are copied and hashed before unmount. This additional campaign is in
602qualification; a local skip is not positive ENOSPC evidence. Physical-media,
603EDQUOT, EIO, sync and power-loss acceptance remain separate.
tests/fixtures/updates/host-volume-full.tsadded+234-0
Changes to tests/fixtures/updates/host-volume-full.ts
@@ -0,0 +1,234 @@
1import assert from 'node:assert/strict'
2import { randomBytes } from 'node:crypto'
3import { closeSync, constants, ftruncateSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, statfsSync, writeSync } from 'node:fs'
4import { cp, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
5import { basename, dirname, join, resolve } from 'node:path'
6import { buildRuntime } from '../../../resources/functions/drone/runtime/build'
7import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
8import { abandonRuntimeFirmwareTrial, createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
9import { runBoundedHostProcess } from '../../../resources/functions/host/process'
10import { createHostSourceProject } from './host-project'
11import { digest, repository, target } from './repository'
12import { storageErrorCodes } from './storage-errors'
13
14const volumeBytes = 16777216, reserveBytes = 1048576
15const clock = { source: 'independent-full-volume-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') }
16
17function privateNamespace() {
18 assert.equal(process.platform, 'linux'); assert.equal(process.env.RUNTIME_HOST_ENOSPC_TEST, '1')
19 assert.equal(process.getuid?.(), 0)
20 const current = readlinkSync('/proc/self/ns/mnt')
21 assert.notEqual(current, readlinkSync('/proc/1/ns/mnt'), 'Use an actual private mount namespace')
22 const mounts = readFileSync('/proc/self/mountinfo', 'utf8')
23 assert.ok(mounts.length > 0 && mounts.length < 2097152)
24 for (const line of mounts.trim().split('\n')) {
25 const [fields, filesystem] = line.split(' - ')
26 assert.ok(fields && filesystem)
27 assert.ok(!fields.split(' ').slice(6).some(field => /^(shared|master|propagate_from):/.test(field)), 'Require recursively private mount propagation before creating a filesystem')
28 }
29 return current
30}
31
32function checkedVolume(volume: string) {
33 const identity = lstatSync(volume), stats = statfsSync(volume)
34 assert.ok(identity.isDirectory() && !identity.isSymbolicLink() && identity.uid === process.getuid?.() && (identity.mode & 0o777) === 0o700)
35 assert.equal(stats.type, 0x01021994, 'Require actual Linux tmpfs')
36 assert.ok(stats.bsize >= 4096 && stats.bsize <= 65536 && reserveBytes % stats.bsize === 0)
37 assert.equal(stats.blocks * stats.bsize, volumeBytes)
38 return { device: identity.dev, blockBytes: stats.bsize, capacityBytes: stats.blocks * stats.bsize, freeBytes: stats.bfree * stats.bsize }
39}
40
41function object(value: unknown) {
42 assert.ok(value && typeof value === 'object' && !Array.isArray(value))
43 return value as Record<string, unknown>
44}
45function text(value: unknown) { assert.equal(typeof value, 'string'); return value as string }
46function identity(value: unknown, length: 32 | 64) { const result = text(value); assert.match(result, new RegExp(`^[a-f0-9]{${length}}$`)); return result }
47
48async function workerInput(path: string) {
49 const namespace = privateNamespace(), root = dirname(resolve(path)), volume = join(root, 'volume')
50 assert.equal(basename(path), 'worker-input.json')
51 const item = object(await Bun.file(path).json()), marker = object(await Bun.file(join(root, 'fixture.json')).json())
52 assert.equal(marker.kind, 'host-full-volume-v1'); assert.equal(item.nonce, marker.nonce)
53 assert.equal(item.namespace, namespace); identity(item.nonce, 32)
54 assert.equal(readFileSync(join(volume, 'nonce'), 'utf8'), item.nonce)
55 checkedVolume(volume)
56 const reference = object(item.reference)
57 assert.equal(reference.directory, join(volume, 'store')); assert.equal(reference.enrollmentDirectory, join(root, 'enrollment'))
58 assert.equal(item.project, join(root, 'project')); assert.equal(item.repository, join(root, 'repository'))
59 return { root, volume, project: text(item.project), repository: text(item.repository), candidateId: identity(item.candidateId, 32), trialId: item.trialId === undefined ? undefined : identity(item.trialId, 32), reference: {
60 directory: text(reference.directory), enrollmentDirectory: text(reference.enrollmentDirectory), enrollmentSha256: identity(reference.enrollmentSha256, 64), storeSha256: identity(reference.storeSha256, 64),
61 } }
62}
63
64function fillVolume(volume: string) {
65 const before = checkedVolume(volume), path = join(volume, 'filler.bin')
66 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
67 let written = 0, full = false
68 try {
69 const bytes = Buffer.alloc(65536, 0xa5)
70 while (written <= volumeBytes) {
71 try { const count = writeSync(fd, bytes); assert.ok(count > 0); written += count }
72 catch (error) { assert.ok(storageErrorCodes(error).includes('ENOSPC')); full = true; break }
73 }
74 assert.equal(full, true); assert.equal(checkedVolume(volume).freeBytes, 0)
75 assert.ok(written > reserveBytes); ftruncateSync(fd, written - reserveBytes)
76 const after = checkedVolume(volume)
77 assert.equal(after.freeBytes, reserveBytes); assert.equal(after.device, before.device)
78 return { before, after, written, fillerBytes: written - reserveBytes, fillError: 'ENOSPC' }
79 }
80 finally { closeSync(fd) }
81}
82
83async function limited(inputPath: string) {
84 const input = await workerInput(inputPath), phases: string[] = []
85 let rejected = false, codes: string[] = []
86 try {
87 await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.repository, project: input.project, candidateId: input.candidateId, clock, async onProgress(event) {
88 assert.ok(phases.length < 512); phases.push(event.phase)
89 if (event.phase === 'slot-selected') await writeFile(join(input.root, 'fill.json'), JSON.stringify(fillVolume(input.volume), null, 2))
90 } })
91 }
92 catch (error) { rejected = true; codes = storageErrorCodes(error) }
93 assert.equal(rejected, true, 'Actual filesystem exhaustion must reject confirmation')
94 return { errorCodes: codes, phases, volumeAfterFailure: checkedVolume(input.volume) }
95}
96
97function volumeFiles(root: string) {
98 const records: { path: string, byteLength: number, sha256: string }[] = []
99 let total = 0
100 function walk(path: string, relative: string) {
101 for (const name of readdirSync(path).sort()) {
102 const child = join(path, name), next = relative ? `${relative}/${name}` : name, stat = lstatSync(child)
103 assert.ok(!stat.isSymbolicLink())
104 if (stat.isDirectory()) walk(child, next)
105 else {
106 assert.ok(stat.isFile() && stat.nlink === 1 && stat.size <= volumeBytes)
107 assert.ok(records.length < 512); total += stat.size; assert.ok(total <= volumeBytes)
108 records.push({ path: next, byteLength: stat.size, sha256: digest(readFileSync(child)) })
109 }
110 }
111 }
112 walk(root, '')
113 return records
114}
115
116async function archiveVolume(volume: string, output: string) {
117 const before = volumeFiles(volume)
118 await cp(volume, output, { recursive: true, errorOnExist: true, force: false })
119 assert.deepEqual(volumeFiles(output), before)
120 await writeFile(`${output}.json`, JSON.stringify(before, null, 2))
121}
122
123/** Full bounded tmpfs, genuine ENOSPC/SQLite failure, and fresh public recovery. */
124export async function qualifyHostFullVolume(directory: string) {
125 const namespace = privateNamespace()
126 directory = resolve(directory)
127 await mkdir(directory, { mode: 0o700 })
128 const volume = join(directory, 'volume'), nonce = randomBytes(16).toString('hex')
129 await mkdir(volume, { mode: 0o700 })
130 await writeFile(join(directory, 'fixture.json'), JSON.stringify({ kind: 'host-full-volume-v1', nonce, namespace }), { mode: 0o600 })
131 async function command(argv: string[], label: string, timeoutMs = 10000) {
132 const result = await runBoundedHostProcess(argv, { cwd: directory, env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, timeoutMs, stdoutBytes: 16384, stderrBytes: 16384, label })
133 assert.equal(result.exitCode, 0, result.stderr)
134 return result
135 }
136 await command(['mount', '--no-mtab', '-t', 'tmpfs', '-o', `size=${volumeBytes},mode=0700,nosuid,nodev,noexec`, 'drone-runtime-test', volume], 'Private bounded update filesystem mount')
137 try {
138 const mounted = checkedVolume(volume)
139 assert.notEqual(mounted.device, lstatSync(directory).dev)
140 await writeFile(join(volume, 'nonce'), nonce, { mode: 0o600 })
141 const { project, sourceRevision } = await createHostSourceProject(directory, { imagePadding: true })
142 const built = await buildRuntime({ project }), image = await readFile(built.artifact)
143 assert.ok(image.length > reserveBytes && image.length < 4 * reserveBytes)
144 const source = join(directory, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
145 const enrollmentDirectory = join(directory, 'enrollment')
146 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
147 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(volume, 'store') })
148 const reference = { directory: join(volume, 'store'), enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }
149 const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
150 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id })
151 assert.equal(good.knownGood!.description.releaseSequence, 7)
152 await signed.publish({ image, version: 2, custom: { releaseSequence: 9 } })
153 const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
154 await signed.publish({ image, version: 3, custom: { releaseSequence: 9 }, rotateTargets: true })
155 const inputPath = join(directory, 'worker-input.json'), config = join(directory, 'worker.bunfig.toml')
156 const input = { nonce, namespace, reference, project, repository: source, candidateId: candidate.candidate!.id }
157 await writeFile(inputPath, JSON.stringify(input), { mode: 0o600 }); await writeFile(config, 'preload=[]\n', { mode: 0o600 })
158 async function worker(mode: 'limited' | 'recover' | 'abandon' | 'fresh') {
159 const result = await runBoundedHostProcess([process.execPath, '--no-env-file', `--config=${config}`, import.meta.path, mode, inputPath], { cwd: directory, env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, timeoutMs: 60000, stdoutBytes: 16384, stderrBytes: 16384, label: `Full update filesystem ${mode}` })
160 await writeFile(join(directory, `${mode}.stdout`), result.stdout)
161 await writeFile(join(directory, `${mode}.stderr`), result.stderr)
162 await writeFile(join(directory, `${mode}.json`), JSON.stringify({ exitCode: result.exitCode }))
163 assert.equal(result.exitCode, 0, result.stderr); assert.equal(result.stderr, '')
164 return result.stdout
165 }
166 const rejection = object(JSON.parse(await worker('limited')))
167 assert.ok(Array.isArray(rejection.errorCodes) && rejection.errorCodes.every(code => typeof code === 'string' && code.length <= 64) && rejection.errorCodes.length <= 32)
168 assert.ok(rejection.errorCodes.includes('ENOSPC') && rejection.errorCodes.includes('SQLITE_FULL'))
169 assert.ok(Array.isArray(rejection.phases) && rejection.phases.includes('slot-selected'))
170 assert.ok(!rejection.phases.includes('slot-image-written') && !rejection.phases.includes('runtime-started') && !rejection.phases.includes('runtime-input-committed'))
171 assert.equal(checkedVolume(volume).freeBytes, 0)
172 await writeFile(join(directory, 'full-volume.json'), JSON.stringify(checkedVolume(volume), null, 2))
173 await archiveVolume(volume, join(directory, 'full-volume'))
174 await unlink(join(volume, 'filler.bin'))
175 const failed = await inspectRuntimeFirmwareSlots(reference)
176 await writeFile(join(directory, 'failed-slots.json'), JSON.stringify(failed, null, 2))
177 assert.deepEqual(failed.knownGood, good.knownGood); assert.deepEqual(failed.installed, good.installed)
178 assert.equal(failed.trial!.phase, 'selected'); assert.equal(failed.trial!.health, null)
179 assert.equal(failed.trial!.integrity, 'mismatch'); assert.equal(failed.store.attempt!.phase, 'pending')
180 assert.equal(failed.store.releaseFloor, 9)
181 const version = (role: string) => failed.store.metadata.find(row => row.role === role)!.version
182 assert.equal(version('root'), 2); assert.equal(version('targets'), 3)
183 const partialPath = join(reference.directory, 'host-slots', failed.trial!.id, 'image.bin'), partial = await readFile(partialPath)
184 assert.ok(partial.length > 0 && partial.length < image.length); assert.deepEqual(partial, image.subarray(0, partial.length))
185 const previousImage = join(reference.directory, 'host-slots', good.knownGood!.id, 'image.bin')
186 assert.deepEqual(await readFile(previousImage), image)
187 await archiveVolume(volume, join(directory, 'failed-volume'))
188 await assert.rejects(abandonRuntimeFirmwareTrial({ ...reference, trialId: failed.trial!.id }), /Resolve metadata/)
189 await writeFile(inputPath, JSON.stringify({ ...input, trialId: failed.trial!.id }))
190 assert.equal(await worker('recover'), 'RECOVERY_COMPLETED\n')
191 const recovered = await inspectRuntimeFirmwareSlots(reference)
192 await writeFile(join(directory, 'recovered-slots.json'), JSON.stringify(recovered, null, 2))
193 assert.deepEqual(recovered.trial, failed.trial); assert.deepEqual(recovered.knownGood, good.knownGood)
194 assert.equal(recovered.store.attempt!.phase, 'recovered'); assert.equal(recovered.store.releaseFloor, 9)
195 assert.deepEqual(recovered.store.metadata, failed.store.metadata)
196 assert.equal(await worker('abandon'), 'ABANDON_COMPLETED\n')
197 const abandoned = await inspectRuntimeFirmwareSlots(reference)
198 await writeFile(join(directory, 'abandoned-slots.json'), JSON.stringify(abandoned, null, 2))
199 assert.equal(abandoned.trial!.phase, 'abandoned'); assert.equal(abandoned.trial!.health, null)
200 assert.deepEqual(abandoned.knownGood, good.knownGood)
201 assert.equal(await worker('fresh'), 'FRESH_TRIAL_COMPLETED\n')
202 const fresh = await inspectRuntimeFirmwareSlots(reference)
203 await writeFile(join(directory, 'confirmed-slots.json'), JSON.stringify(fresh, null, 2))
204 assert.equal(fresh.knownGood!.description.releaseSequence, 9); assert.equal(fresh.knownGood!.health!.cleanProcessExit, true)
205 assert.notEqual(fresh.knownGood!.id, failed.trial!.id)
206 assert.notEqual(fresh.knownGood!.health!.bootId, good.knownGood!.health!.bootId)
207 assert.notEqual(fresh.knownGood!.health!.sessionId, good.knownGood!.health!.sessionId)
208 assert.deepEqual(await readFile(partialPath), partial); assert.deepEqual(await readFile(previousImage), image)
209 assert.deepEqual(await readFile(join(reference.directory, 'host-slots', fresh.knownGood!.id, 'image.bin')), image)
210 const result = { evidence: 'host-actual-enospc-v1', filesystemType: 'tmpfs', filesystemBytes: mounted.capacityBytes, freeBytesAfterFailure: 0, sourceRevision, authorizedImageBytes: image.length, authorizedImageSha256: signed.imageHash, partialImageBytes: partial.length, partialImageSha256: digest(partial), errorCodes: rejection.errorCodes, interruptedPhase: failed.trial!.phase, interruptedAttempt: failed.store.attempt!.phase, acceptedRootVersion: version('root'), acceptedTargetsVersion: version('targets'), releaseFloor: fresh.store.releaseFloor, abandonedPhase: abandoned.trial!.phase, previousSlotId: good.knownGood!.id, failedSlotId: failed.trial!.id, confirmedSlotId: fresh.knownGood!.id, previousBootId: good.knownGood!.health!.bootId, freshBootId: fresh.knownGood!.health!.bootId, physicalDeploymentAllowed: fresh.physicalDeploymentAllowed }
211 await writeFile(join(directory, 'qualification.json'), JSON.stringify(result, null, 2))
212 return result
213 }
214 finally {
215 try { await archiveVolume(volume, join(directory, 'final-volume')) }
216 finally { await command(['umount', '--no-mtab', volume], 'Private bounded update filesystem unmount') }
217 }
218}
219
220if (import.meta.main) {
221 try {
222 assert.equal(process.argv.length, 4)
223 const mode = process.argv[2], path = resolve(process.argv[3]!)
224 if (mode === 'qualify') console.log(JSON.stringify(await qualifyHostFullVolume(path)))
225 else if (mode === 'limited') console.log(JSON.stringify(await limited(path)))
226 else {
227 const input = await workerInput(path)
228 if (mode === 'recover') { await recoverRuntimeTrustStore(input.reference); console.log('RECOVERY_COMPLETED') }
229 else if (mode === 'abandon') { assert.ok(input.trialId); await abandonRuntimeFirmwareTrial({ ...input.reference, trialId: input.trialId }); console.log('ABANDON_COMPLETED') }
230 else { assert.equal(mode, 'fresh'); await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.repository, project: input.project, candidateId: input.candidateId, clock }); console.log('FRESH_TRIAL_COMPLETED') }
231 }
232 }
233 catch (error) { console.error('Host full-volume fixture failed', error); process.exitCode = 1 }
234}
tests/fixtures/updates/host-write-limit.tsmodified+2-17
Changes to tests/fixtures/updates/host-write-limit.ts
@@ -8,26 +8,11 @@ import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTru
88import { runBoundedHostProcess } from '../../../resources/functions/host/process'
99import { createHostSourceProject } from './host-project'
1010import { digest, repository, target } from './repository'
11import { storageErrorCodes } from './storage-errors'
1112
1213const fileLimitBytes = 1048576
1314const clock = { source: 'independent-resource-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') }
1415
15function errorCodes(error: unknown) {
16 const pending = [error], seen = new Set<unknown>(), codes: string[] = []
17 while (pending.length && seen.size < 32) {
18 const item = pending.shift()
19 if (!item || typeof item !== 'object' || seen.has(item)) continue
20 seen.add(item)
21 const code = Object.getOwnPropertyDescriptor(item, 'code')?.value
22 if ((typeof code === 'string' || typeof code === 'number') && String(code).length <= 64) codes.push(String(code))
23 const cause = Object.getOwnPropertyDescriptor(item, 'cause')?.value
24 if (cause !== undefined) pending.push(cause)
25 const errors = Object.getOwnPropertyDescriptor(item, 'errors')?.value
26 if (Array.isArray(errors)) pending.push(...errors.slice(0, 32))
27 }
28 return codes
29}
30
3116async function fixtureInput(inputPath: string) {
3217 assert.equal(basename(inputPath), 'worker-input.json')
3318 const root = dirname(resolve(inputPath))
@@ -51,7 +36,7 @@ async function limitedTrial(inputPath: string) {
5136 assert.ok(phases.length < 512); phases.push(event.phase)
5237 } })
5338 }
54 catch (error) { caught = true; codes = errorCodes(error) }
39 catch (error) { caught = true; codes = storageErrorCodes(error) }
5540 assert.equal(caught, true, 'The actual kernel file limit must reject the trial')
5641 return { errorCodes: codes, phases }
5742}
tests/fixtures/updates/storage-errors.tsadded+16-0
Changes to tests/fixtures/updates/storage-errors.ts
@@ -0,0 +1,16 @@
1/** Bounded own error descriptors only, including actual SQLite/OS aggregate causes. */
2export function storageErrorCodes(error: unknown) {
3 const pending = [error], seen = new Set<unknown>(), codes: string[] = []
4 while (pending.length && seen.size < 32) {
5 const item = pending.shift()
6 if (!item || typeof item !== 'object' || seen.has(item)) continue
7 seen.add(item)
8 const code = Object.getOwnPropertyDescriptor(item, 'code')?.value
9 if ((typeof code === 'string' || typeof code === 'number') && String(code).length <= 64) codes.push(String(code))
10 const cause = Object.getOwnPropertyDescriptor(item, 'cause')?.value
11 if (cause !== undefined) pending.push(cause)
12 const errors = Object.getOwnPropertyDescriptor(item, 'errors')?.value
13 if (Array.isArray(errors)) pending.push(...errors.slice(0, 32))
14 }
15 return codes
16}
tests/integration/runtime-host-storage.test.tsadded+36-0
Changes to tests/integration/runtime-host-storage.test.ts
@@ -0,0 +1,36 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { qualifyHostFullVolume } from '../fixtures/updates/host-volume-full'
6
7// Actual privileged Linux namespace qualification only. A skip is not ENOSPC evidence.
8test.skipIf(process.env.RUNTIME_HOST_ENOSPC_TEST !== '1')('a full host filesystem preserves accepted trust and requires explicit recovery before fresh confirmation', async () => {
9 const evidence = process.env.RUNTIME_HOST_ENOSPC_EVIDENCE
10 const temporary = evidence ? undefined : await mkdtemp(join(tmpdir(), 'drone-host-enospc-'))
11 const root = evidence ? resolve(evidence) : join(temporary!, 'campaign')
12 let completed = false
13 try {
14 const result = await qualifyHostFullVolume(root)
15 expect(result.filesystemType).toBe('tmpfs')
16 expect(result.filesystemBytes).toBe(16777216)
17 expect(result.freeBytesAfterFailure).toBe(0)
18 expect(result.partialImageBytes).toBeGreaterThan(0)
19 expect(result.partialImageBytes).toBeLessThan(result.authorizedImageBytes)
20 expect(result.errorCodes).toContain('ENOSPC')
21 expect(result.errorCodes).toContain('SQLITE_FULL')
22 expect(result.interruptedPhase).toBe('selected')
23 expect(result.interruptedAttempt).toBe('pending')
24 expect(result.acceptedRootVersion).toBe(2)
25 expect(result.acceptedTargetsVersion).toBe(3)
26 expect(result.releaseFloor).toBe(9)
27 expect(result.abandonedPhase).toBe('abandoned')
28 expect(result.freshBootId).not.toBe(result.previousBootId)
29 expect(result.physicalDeploymentAllowed).toBe(false)
30 completed = true
31 }
32 finally {
33 if (completed && temporary) await rm(temporary, { recursive: true, force: true })
34 else console.log(`Host full-volume campaign retained at ${root}`)
35 }
36}, 180000)
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -33,6 +33,7 @@
3333 "../resources/functions/drone/updates/**/*.ts",
3434 "./unit/updates*.test.ts",
3535 "./fixtures/updates/**/*.ts",
36 "./integration/runtime-host-storage.test.ts",
3637 "./unit/diagnostics.test.ts",
3738 "./fixtures/diagnostics/**/*.ts",
3839 "../resources/functions/drone/missions/**/*.ts",