@b00dea/cockpit 0.0.0-bootstrap.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +21 -1
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +76 -1
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { sha256Hex } from '../sha256.js';
|
|
3
|
+
import { open, readFile, readdir, realpath, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { arch, platform } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { admitLocalDockerEndpoint } from './docker-endpoint.js';
|
|
8
|
+
import { authenticatedExecutionOutcome, executeProbe } from '../environment-exec/execute.js';
|
|
9
|
+
import { ENVIRONMENT_CODES, errorCause, isMissing, RefusalError } from './contract.js';
|
|
10
|
+
import { isResolvedDockerExecutable, isResolvedGitExecutable } from './executable.js';
|
|
11
|
+
import { runDown, runReset } from './application/destructive.js';
|
|
12
|
+
import { runSleep, runUp } from './application/lifecycle.js';
|
|
13
|
+
import { runLogs } from './application/logs.js';
|
|
14
|
+
import { createDestructiveCommandAdapters } from './destructive-composition.js';
|
|
15
|
+
import { createLifecycleCommandAdapters } from './lifecycle-composition.js';
|
|
16
|
+
import { createReadCommandAdapters } from './read-composition.js';
|
|
17
|
+
import { decodeCoordinationRecord, readActiveStackInventory } from './claims.js';
|
|
18
|
+
import { readState } from './state.js';
|
|
19
|
+
import { COMPOSE_OWNERSHIP_FILE, renderComposeOwnership } from './compose-ownership.js';
|
|
20
|
+
import { isVersionFloor, issueRuntimeMeasurementProbeAuthority, observeDockerRuntime, versionAtLeast } from './runtime.js';
|
|
21
|
+
import { PROBE_OWNER_LABEL, isProbeOwner } from './runtime-probe-residue.js';
|
|
22
|
+
import { exactKeys } from '../exact-keys.js';
|
|
23
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
24
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
25
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
26
|
+
// A cockpit name, bounded so the probe's project and resource names stay short.
|
|
27
|
+
const PROJECT_PREFIX = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
28
|
+
const SAFE_ENV = new Set(['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'DOCKER_HOST', 'DOCKER_CONTEXT']);
|
|
29
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
30
|
+
const OUTPUT_LIMIT = 1_048_576;
|
|
31
|
+
const PROBE_WINDOW_MS = 300_000;
|
|
32
|
+
const CLEANUP_RESERVE_MS = 90_000;
|
|
33
|
+
// The candidate's data volume: its Compose key, which `resettable_volumes` names.
|
|
34
|
+
const PROBE_VOLUME_KEY = 'probe_data';
|
|
35
|
+
function validDate(value) {
|
|
36
|
+
const match = DATE.exec(value);
|
|
37
|
+
if (match === null)
|
|
38
|
+
return false;
|
|
39
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
40
|
+
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
|
|
41
|
+
}
|
|
42
|
+
function unavailable(input, reason, timedOut = false, evidence = null, brokenControl = 'inconclusive') {
|
|
43
|
+
return {
|
|
44
|
+
status: 'unavailable', runtime: null, composeVersion: null,
|
|
45
|
+
composeFloor: typeof input.composeFloor === 'string' ? input.composeFloor : '',
|
|
46
|
+
measuredOn: typeof input.measuredOn === 'string' ? input.measuredOn : '',
|
|
47
|
+
candidate: 'failed', brokenControl, evidence,
|
|
48
|
+
operatorAction: {
|
|
49
|
+
required: true,
|
|
50
|
+
code: timedOut ? ENVIRONMENT_CODES.RUNTIME_PROBE_TIMEOUT : ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE,
|
|
51
|
+
reason,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function captureRequest(value) {
|
|
56
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
57
|
+
|| !exactKeys(value, ['executable', 'gitExecutable', 'cwd', 'projectPrefix', 'owner', 'env', 'composeFloor', 'measuredOn', 'timeoutMs', 'image', 'installedModule']))
|
|
58
|
+
return undefined;
|
|
59
|
+
const input = value;
|
|
60
|
+
if (!isResolvedDockerExecutable(input.executable)
|
|
61
|
+
|| !isResolvedGitExecutable(input.gitExecutable)
|
|
62
|
+
|| typeof input.cwd !== 'string' || !isAbsolute(input.cwd) || normalize(input.cwd) !== input.cwd || input.cwd.includes('\0')
|
|
63
|
+
|| typeof input.projectPrefix !== 'string' || !PROJECT_PREFIX.test(input.projectPrefix)
|
|
64
|
+
|| typeof input.owner !== 'string' || !isProbeOwner(input.owner)
|
|
65
|
+
|| input.env === null || typeof input.env !== 'object' || Array.isArray(input.env)
|
|
66
|
+
|| Object.entries(input.env).some(([name, item]) => !SAFE_ENV.has(name) || typeof item !== 'string' || item.includes('\0'))
|
|
67
|
+
|| typeof input.composeFloor !== 'string' || !isVersionFloor(input.composeFloor)
|
|
68
|
+
|| typeof input.measuredOn !== 'string' || !validDate(input.measuredOn)
|
|
69
|
+
|| input.measuredOn !== new Date().toISOString().slice(0, 10)
|
|
70
|
+
|| !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > MAX_TIMEOUT_MS
|
|
71
|
+
|| input.image === null || typeof input.image !== 'object' || Array.isArray(input.image)
|
|
72
|
+
|| !exactKeys(input.image, ['reference', 'id']) || typeof input.image.reference !== 'string'
|
|
73
|
+
|| typeof input.image.id !== 'string' || !IMAGE_ID.test(input.image.id)
|
|
74
|
+
|| input.image.reference !== input.image.id
|
|
75
|
+
|| input.installedModule === null || typeof input.installedModule !== 'object' || Array.isArray(input.installedModule)
|
|
76
|
+
|| !exactKeys(input.installedModule, ['canonicalPath', 'sha256'])
|
|
77
|
+
|| typeof input.installedModule.canonicalPath !== 'string' || !isAbsolute(input.installedModule.canonicalPath)
|
|
78
|
+
|| normalize(input.installedModule.canonicalPath) !== input.installedModule.canonicalPath
|
|
79
|
+
|| typeof input.installedModule.sha256 !== 'string' || !SHA256.test(input.installedModule.sha256))
|
|
80
|
+
return undefined;
|
|
81
|
+
const captured = input;
|
|
82
|
+
return {
|
|
83
|
+
...captured,
|
|
84
|
+
executable: { ...captured.executable },
|
|
85
|
+
gitExecutable: { ...captured.gitExecutable },
|
|
86
|
+
env: { ...captured.env },
|
|
87
|
+
image: { ...captured.image },
|
|
88
|
+
installedModule: { ...captured.installedModule },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function confined(root, target) {
|
|
92
|
+
const path = relative(root, target);
|
|
93
|
+
return path !== '' && !path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path);
|
|
94
|
+
}
|
|
95
|
+
function decode(outcome) {
|
|
96
|
+
if (outcome.kind !== 'complete')
|
|
97
|
+
return undefined;
|
|
98
|
+
// Preserve endpoint-edge whitespace so locality admission can refuse identity aliases.
|
|
99
|
+
try {
|
|
100
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(outcome.stdout).replace(/\r?\n$/, '');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Undecodable output is reported by each caller as an unreadable result.
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function refusalCategory(outcome) {
|
|
108
|
+
if (outcome.kind !== 'refusal')
|
|
109
|
+
return 'unreadable-output';
|
|
110
|
+
let stderr = '';
|
|
111
|
+
try {
|
|
112
|
+
stderr = new TextDecoder('utf-8', { fatal: true }).decode(outcome.stderr);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Undecodable stderr cannot be categorised; the refusal code alone is the category.
|
|
116
|
+
return outcome.code;
|
|
117
|
+
}
|
|
118
|
+
if (/unknown flag/i.test(stderr))
|
|
119
|
+
return `${outcome.code}:unknown-flag`;
|
|
120
|
+
if (/cannot|incompatible|conflict/i.test(stderr))
|
|
121
|
+
return `${outcome.code}:incompatible-options`;
|
|
122
|
+
if (/services\.probe\.labels/i.test(stderr))
|
|
123
|
+
return `${outcome.code}:service-labels`;
|
|
124
|
+
if (/volumes\..*labels/i.test(stderr))
|
|
125
|
+
return `${outcome.code}:volume-labels`;
|
|
126
|
+
if (/networks\..*labels/i.test(stderr))
|
|
127
|
+
return `${outcome.code}:network-labels`;
|
|
128
|
+
if (/services\.probe\.command/i.test(stderr))
|
|
129
|
+
return `${outcome.code}:service-command`;
|
|
130
|
+
if (/services\.probe\.volumes/i.test(stderr))
|
|
131
|
+
return `${outcome.code}:service-volumes`;
|
|
132
|
+
if (/services\.probe\.healthcheck/i.test(stderr))
|
|
133
|
+
return `${outcome.code}:service-healthcheck`;
|
|
134
|
+
if (/no such file|cannot.*file|failed to read/i.test(stderr))
|
|
135
|
+
return `${outcome.code}:unreadable-artifact`;
|
|
136
|
+
if (/additional propert/i.test(stderr))
|
|
137
|
+
return `${outcome.code}:unsupported-field`;
|
|
138
|
+
if (/yaml|validat|invalid|parse|decode/i.test(stderr))
|
|
139
|
+
return `${outcome.code}:invalid-config`;
|
|
140
|
+
if (/no such image|not found/i.test(stderr))
|
|
141
|
+
return `${outcome.code}:missing-image`;
|
|
142
|
+
return outcome.code;
|
|
143
|
+
}
|
|
144
|
+
function generatedArtifactDetail(outcome) {
|
|
145
|
+
if (outcome.kind !== 'refusal')
|
|
146
|
+
return 'unreadable output';
|
|
147
|
+
try {
|
|
148
|
+
const first = new TextDecoder('utf-8', { fatal: true }).decode(outcome.stderr).split('\n', 1)[0] ?? '';
|
|
149
|
+
return first.replaceAll(/[a-f0-9]{32,}/g, '<identity>').replaceAll(/\/[\w./-]+/g, '<path>').slice(0, 256);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Undecodable stderr has no first line to show; the category stands in for it.
|
|
153
|
+
return refusalCategory(outcome);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function runner(input, batchId, deadline) {
|
|
157
|
+
let sawTimeout = false;
|
|
158
|
+
return {
|
|
159
|
+
timedOut: () => sawTimeout,
|
|
160
|
+
run: async (id, args, artifact, selectedExecutable = input.executable) => {
|
|
161
|
+
const remainingMs = deadline - Date.now();
|
|
162
|
+
if (remainingMs <= 0) {
|
|
163
|
+
sawTimeout = true;
|
|
164
|
+
throw new RefusalError('the absolute runtime observation window expired before command spawn');
|
|
165
|
+
}
|
|
166
|
+
const argv = [selectedExecutable.canonicalPath, ...args];
|
|
167
|
+
const artifactIndexes = artifact === undefined ? [] : argv.flatMap((part, index) => part === artifact.path ? [index] : []);
|
|
168
|
+
const step = {
|
|
169
|
+
id: `runtime:capability:${id}`,
|
|
170
|
+
argv, argv0: selectedExecutable.invocationPath,
|
|
171
|
+
cwd: input.cwd, env: input.env, timeoutMs: Math.min(input.timeoutMs, remainingMs),
|
|
172
|
+
stdoutLimitBytes: OUTPUT_LIMIT, stderrLimitBytes: OUTPUT_LIMIT,
|
|
173
|
+
paths: artifact === undefined ? [] : [artifact.path], ports: [],
|
|
174
|
+
labels: { batchId, subject: id },
|
|
175
|
+
expected: [],
|
|
176
|
+
consumedFiles: [
|
|
177
|
+
{
|
|
178
|
+
path: selectedExecutable.canonicalPath, sha256: selectedExecutable.sha256,
|
|
179
|
+
maxBytes: 536_870_912, argvIndexes: [0], executable: true,
|
|
180
|
+
executionMode: selectedExecutable.executionMode,
|
|
181
|
+
},
|
|
182
|
+
...(artifact === undefined ? [] : [{
|
|
183
|
+
path: artifact.path, sha256: artifact.sha256, maxBytes: 1_048_576,
|
|
184
|
+
argvIndexes: artifactIndexes, executable: false,
|
|
185
|
+
}]),
|
|
186
|
+
],
|
|
187
|
+
filesystemChecks: [], compensation: [],
|
|
188
|
+
};
|
|
189
|
+
const outcome = await executeProbe(step);
|
|
190
|
+
if (!authenticatedExecutionOutcome(outcome, step))
|
|
191
|
+
throw new RefusalError('runtime probe execution outcome is unauthenticated');
|
|
192
|
+
if (outcome.kind === 'refusal' && outcome.code === ENVIRONMENT_CODES.EXECUTION_TIMEOUT)
|
|
193
|
+
sawTimeout = true;
|
|
194
|
+
return outcome;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const composeArgs = (input, projectName, file) => [
|
|
199
|
+
'compose', '--project-directory', input.cwd, '--file', file, '--project-name', projectName,
|
|
200
|
+
];
|
|
201
|
+
// The daemon is often shared with other stacks, so the inventory covers only what this probe can
|
|
202
|
+
// own: anything carrying its owner label or its Compose project label. Compose labels everything it
|
|
203
|
+
// creates for a project, including an implicit default network the owner label never reaches.
|
|
204
|
+
async function inventory(run, scope, labels) {
|
|
205
|
+
const commands = labels.flatMap((label, index) => {
|
|
206
|
+
const filter = ['--filter', `label=${label}`];
|
|
207
|
+
return [
|
|
208
|
+
['containers', `${index}`, ['ps', '-aq', ...filter]],
|
|
209
|
+
['volumes', `${index}`, ['volume', 'ls', '-q', ...filter]],
|
|
210
|
+
['networks', `${index}`, ['network', 'ls', '-q', ...filter]],
|
|
211
|
+
['images', `${index}`, ['image', 'ls', '--no-trunc', '--quiet', ...filter]],
|
|
212
|
+
];
|
|
213
|
+
});
|
|
214
|
+
const found = { containers: new Set(), volumes: new Set(), networks: new Set(), images: new Set() };
|
|
215
|
+
const outcomes = await Promise.all(commands.map(async ([kind, index, args]) => [kind, await run(`${scope}:${kind}:${index}`, args)]));
|
|
216
|
+
for (const [kind, outcome] of outcomes) {
|
|
217
|
+
const text = decode(outcome);
|
|
218
|
+
if (text === undefined)
|
|
219
|
+
return undefined;
|
|
220
|
+
for (const id of text.split('\n').filter(Boolean))
|
|
221
|
+
found[kind].add(id);
|
|
222
|
+
}
|
|
223
|
+
return Object.fromEntries(Object.entries(found).map(([kind, ids]) => [kind, [...ids].sort()]));
|
|
224
|
+
}
|
|
225
|
+
async function exactCleanup(run, owned) {
|
|
226
|
+
let complete = true;
|
|
227
|
+
for (const id of owned.containers)
|
|
228
|
+
complete = (await run(`fallback:container:${id.slice(0, 12)}`, ['container', 'rm', '--force', id])).kind === 'complete' && complete;
|
|
229
|
+
const detached = await Promise.all([
|
|
230
|
+
...owned.networks.map(async (id) => (await run(`fallback:network:${id.slice(0, 12)}`, ['network', 'rm', id])).kind === 'complete'),
|
|
231
|
+
...owned.volumes.map(async (id) => (await run(`fallback:volume:${id.slice(0, 12)}`, ['volume', 'rm', id])).kind === 'complete'),
|
|
232
|
+
]);
|
|
233
|
+
complete = detached.every(Boolean) && complete;
|
|
234
|
+
return complete;
|
|
235
|
+
}
|
|
236
|
+
function stateSnapshot(value) {
|
|
237
|
+
return JSON.stringify(value);
|
|
238
|
+
}
|
|
239
|
+
function currentLeaseSnapshot(result, stackId) {
|
|
240
|
+
if (result.kind !== 'missing' && result.kind !== 'complete')
|
|
241
|
+
return undefined;
|
|
242
|
+
const coordination = decodeCoordinationRecord(result.kind === 'complete' ? result.value : null, stackId);
|
|
243
|
+
if (!coordination)
|
|
244
|
+
return undefined;
|
|
245
|
+
return stateSnapshot(coordination.lease === null || coordination.lease.released ? null : coordination.lease);
|
|
246
|
+
}
|
|
247
|
+
export function currentCoordinationCleanupVerdict(beforeClaims, afterClaims, beforeLease, afterLease, stackId) {
|
|
248
|
+
const beforeSnapshot = currentLeaseSnapshot(beforeLease, stackId);
|
|
249
|
+
const afterSnapshot = currentLeaseSnapshot(afterLease, stackId);
|
|
250
|
+
const inactive = stateSnapshot(null);
|
|
251
|
+
return {
|
|
252
|
+
claimsEqual: beforeClaims.kind === 'complete' && afterClaims.kind === 'complete'
|
|
253
|
+
&& stateSnapshot(beforeClaims.value) === stateSnapshot(afterClaims.value),
|
|
254
|
+
leasesEqual: beforeSnapshot === inactive && afterSnapshot === inactive,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function inventoriesEqual(left, right) {
|
|
258
|
+
return ['containers', 'volumes', 'networks', 'images'].every((kind) => JSON.stringify(left[kind]) === JSON.stringify(right[kind]));
|
|
259
|
+
}
|
|
260
|
+
function inventoryDifferences(left, right) {
|
|
261
|
+
return ['containers', 'volumes', 'networks', 'images']
|
|
262
|
+
.filter((kind) => JSON.stringify(left[kind]) !== JSON.stringify(right[kind]));
|
|
263
|
+
}
|
|
264
|
+
async function emptyDirectory(path) {
|
|
265
|
+
try {
|
|
266
|
+
const canonical = await realpath(path);
|
|
267
|
+
return canonical === path && (await readdir(path)).length === 0;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
// A missing directory is not an empty one; any other failure reaches the caller's refusal.
|
|
271
|
+
if (!isMissing(error))
|
|
272
|
+
throw error;
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function writeExclusive(path, contents) {
|
|
277
|
+
const handle = await open(path, 'wx', 0o600);
|
|
278
|
+
try {
|
|
279
|
+
await handle.writeFile(contents);
|
|
280
|
+
await handle.sync();
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await handle.close();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function probeRuntimeSupport(rawInput) {
|
|
287
|
+
const input = captureRequest(rawInput);
|
|
288
|
+
if (input === undefined)
|
|
289
|
+
return unavailable({}, 'runtime probe input is malformed or contains an unsafe environment');
|
|
290
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
291
|
+
try {
|
|
292
|
+
const [canonicalModule, moduleBytes] = await Promise.all([realpath(modulePath), readFile(modulePath)]);
|
|
293
|
+
const installedSuffix = join('node_modules', '@b00dea', 'cockpit', 'dist', 'environment', 'runtime-probe.js');
|
|
294
|
+
if (!canonicalModule.endsWith(installedSuffix)
|
|
295
|
+
|| canonicalModule !== input.installedModule.canonicalPath || sha256Hex(moduleBytes) !== input.installedModule.sha256
|
|
296
|
+
|| !await emptyDirectory(input.cwd)) {
|
|
297
|
+
return unavailable(input, 'the installed module identity or disposable probe directory could not be authenticated');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
return unavailable(input, `the installed module identity could not be read: ${errorCause(error)}`);
|
|
302
|
+
}
|
|
303
|
+
const startedAt = new Date().toISOString();
|
|
304
|
+
const deadline = Date.parse(startedAt) + PROBE_WINDOW_MS;
|
|
305
|
+
const mutationDeadline = deadline - CLEANUP_RESERVE_MS;
|
|
306
|
+
const expiresAt = new Date(deadline).toISOString();
|
|
307
|
+
const batchId = randomBytes(16).toString('hex');
|
|
308
|
+
// Named after the measuring cockpit so a survivor on a shared daemon says whose it is; the batch
|
|
309
|
+
// keeps it disjoint from that cockpit's own stack and from any concurrent probe.
|
|
310
|
+
const projectName = `${input.projectPrefix}-probe-${batchId}`;
|
|
311
|
+
const ownerLabel = `dev.b0dea.cockpit.probe=${batchId}`;
|
|
312
|
+
const ownerLabelName = 'dev.b0dea.cockpit.probe';
|
|
313
|
+
const probeScope = [ownerLabel, `com.docker.compose.project=${projectName}`];
|
|
314
|
+
const siblingsRoot = join(input.cwd, 'siblings');
|
|
315
|
+
const siblingPath = join(siblingsRoot, 'probe');
|
|
316
|
+
const homePath = join(dirname(input.cwd), `runtime-home-${batchId}`);
|
|
317
|
+
const generatedName = `generated-${batchId}`;
|
|
318
|
+
const generatedPath = join(input.cwd, generatedName);
|
|
319
|
+
const configPath = join(input.cwd, 'cockpit.toml');
|
|
320
|
+
const inputMarkerPath = join(siblingPath, 'input');
|
|
321
|
+
const outputMarkerPath = join(siblingPath, 'output');
|
|
322
|
+
const candidatePath = join(input.cwd, `candidate-${batchId}.yml`);
|
|
323
|
+
const controlPath = join(input.cwd, `broken-${batchId}.yml`);
|
|
324
|
+
const defaultEnvironmentPath = join(input.cwd, '.env');
|
|
325
|
+
const serviceEnvironmentPath = join(siblingPath, 'service.env');
|
|
326
|
+
const ownershipPath = join(input.cwd, COMPOSE_OWNERSHIP_FILE);
|
|
327
|
+
const volumeName = `${projectName}-data`;
|
|
328
|
+
if (![siblingsRoot, siblingPath, generatedPath, configPath, inputMarkerPath, outputMarkerPath, candidatePath, controlPath,
|
|
329
|
+
defaultEnvironmentPath, serviceEnvironmentPath, ownershipPath]
|
|
330
|
+
.every((path) => confined(input.cwd, path)))
|
|
331
|
+
return unavailable(input, 'the disposable probe paths escaped their authenticated root');
|
|
332
|
+
const marker = `cockpit-runtime-${randomBytes(16).toString('hex')}`;
|
|
333
|
+
const brokenSource = 'services:\n probe: [\n';
|
|
334
|
+
let candidateSource = 'services: {}\n';
|
|
335
|
+
let candidateArtifact = { path: candidatePath, sha256: sha256Hex(candidateSource) };
|
|
336
|
+
const controlArtifact = { path: controlPath, sha256: sha256Hex(brokenSource) };
|
|
337
|
+
const execute = runner(input, batchId, mutationDeadline);
|
|
338
|
+
const cleanupExecute = runner(input, batchId, deadline);
|
|
339
|
+
const checks = {
|
|
340
|
+
localRuntime: false, imagePreexisting: false, profile: false, topology: false,
|
|
341
|
+
applicationUp: false, mountHostToContainer: false, mountContainerToHost: false,
|
|
342
|
+
ownership: false, health: false, recovery: false, logs: false,
|
|
343
|
+
resetPreview: false, configuredHostPathTeardown: false, teardown: false,
|
|
344
|
+
};
|
|
345
|
+
let pre;
|
|
346
|
+
let collisionFree = false;
|
|
347
|
+
let runtimeVersion;
|
|
348
|
+
let composeVersion;
|
|
349
|
+
let profileProjected = false;
|
|
350
|
+
let brokenControl = 'inconclusive';
|
|
351
|
+
let resourcesEqual = false;
|
|
352
|
+
let claimsEqual = false;
|
|
353
|
+
let leasesEqual = false;
|
|
354
|
+
let changedResourceKinds = [];
|
|
355
|
+
let ownedSurvivors = [];
|
|
356
|
+
let failureReason;
|
|
357
|
+
let preClaims;
|
|
358
|
+
let preLease;
|
|
359
|
+
let lifecycleEnvironment;
|
|
360
|
+
let dockerContext = '';
|
|
361
|
+
let ownerCleanup;
|
|
362
|
+
let ownerCleanupAttempted = false;
|
|
363
|
+
let ownerTimedOut = false;
|
|
364
|
+
// Owner refusals behind a failed check, so an incomplete verdict names what the owner said.
|
|
365
|
+
const refusals = [];
|
|
366
|
+
const noteRefusal = (step, outcome) => {
|
|
367
|
+
if (outcome.kind === 'refusal')
|
|
368
|
+
refusals.push(`${step}: ${outcome.diagnostics.map(({ code, message }) => `${code}: ${message}`).join('; ') || 'refused'}`);
|
|
369
|
+
};
|
|
370
|
+
const ownerStages = {};
|
|
371
|
+
const executeOwnerStep = async (step, stepDeadline) => {
|
|
372
|
+
if (Date.now() + step.timeoutMs > stepDeadline) {
|
|
373
|
+
ownerTimedOut = true;
|
|
374
|
+
throw new RefusalError('the absolute runtime observation window cannot fit the next authenticated owner step');
|
|
375
|
+
}
|
|
376
|
+
const outcome = await executeProbe(step);
|
|
377
|
+
if (outcome.kind === 'refusal' && outcome.code === ENVIRONMENT_CODES.EXECUTION_TIMEOUT)
|
|
378
|
+
ownerTimedOut = true;
|
|
379
|
+
if (step.id.startsWith('runtime:docker-compose:') || step.id.startsWith('runtime:docker-container:')) {
|
|
380
|
+
ownerStages[step.id] = outcome.kind === 'complete'
|
|
381
|
+
? `complete:stderr=${outcome.stderr.byteLength}` : `${outcome.kind}:${outcome.code}`;
|
|
382
|
+
}
|
|
383
|
+
return outcome;
|
|
384
|
+
};
|
|
385
|
+
const ownerExecute = async (step) => executeOwnerStep(step, mutationDeadline);
|
|
386
|
+
const cleanupOwnerExecute = async (step) => executeOwnerStep(step, deadline);
|
|
387
|
+
const fail = (reason) => { throw new RefusalError(reason); };
|
|
388
|
+
const fresh = () => { if (Date.now() >= mutationDeadline)
|
|
389
|
+
fail('the runtime mutation window closed to reserve authenticated cleanup'); };
|
|
390
|
+
try {
|
|
391
|
+
// A remote daemon's behaviour says nothing about this machine. Keep the selected endpoint
|
|
392
|
+
// exact so a host override cannot borrow a different context's runtime evidence.
|
|
393
|
+
const dockerHost = input.env.DOCKER_HOST;
|
|
394
|
+
if (dockerHost !== undefined && admitLocalDockerEndpoint(dockerHost) === undefined) {
|
|
395
|
+
fail('DOCKER_HOST does not name a local Docker endpoint; unset it or point it at a local Unix socket');
|
|
396
|
+
}
|
|
397
|
+
await Promise.all([
|
|
398
|
+
mkdir(siblingPath, { recursive: true, mode: 0o700 }),
|
|
399
|
+
mkdir(homePath, { mode: 0o700 }),
|
|
400
|
+
mkdir(generatedPath, { mode: 0o700 }),
|
|
401
|
+
]);
|
|
402
|
+
await writeFile(inputMarkerPath, `${marker}\n`, { flag: 'wx', mode: 0o600 });
|
|
403
|
+
await writeExclusive(candidatePath, candidateSource);
|
|
404
|
+
await writeExclusive(controlPath, brokenSource);
|
|
405
|
+
const git = await execute.run('git-init', ['init', '--quiet', '.'], undefined, input.gitExecutable);
|
|
406
|
+
if (git.kind !== 'complete')
|
|
407
|
+
fail('the disposable installed-child checkout could not be initialized');
|
|
408
|
+
const runtimeEnvironment = Object.fromEntries(Object.entries(input.env).filter(([name]) => ['PATH', 'DOCKER_HOST', 'DOCKER_CONTEXT'].includes(name)));
|
|
409
|
+
dockerContext = decode(await execute.run('context-name', ['context', 'show'])) ?? '';
|
|
410
|
+
const endpoint = dockerContext === '' ? undefined
|
|
411
|
+
: decode(await execute.run('context-endpoint', ['context', 'inspect', dockerContext, '--format', '{{.Endpoints.docker.Host}}']));
|
|
412
|
+
// A named context is the daemon `up` will reach, so it is the one measured, never the active one.
|
|
413
|
+
checks.localRuntime = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(dockerContext) && admitLocalDockerEndpoint(endpoint) !== undefined
|
|
414
|
+
&& (dockerHost === undefined ? input.env.DOCKER_CONTEXT === undefined || dockerContext === input.env.DOCKER_CONTEXT : endpoint === dockerHost);
|
|
415
|
+
if (!checks.localRuntime)
|
|
416
|
+
fail('remote or unauthenticated Docker endpoints cannot establish local runtime support');
|
|
417
|
+
const runtime = await observeDockerRuntime({
|
|
418
|
+
executable: input.executable, cwd: input.cwd, env: runtimeEnvironment,
|
|
419
|
+
timeoutMs: Math.min(input.timeoutMs, 30_000),
|
|
420
|
+
}, ownerExecute);
|
|
421
|
+
if (runtime.kind !== 'complete')
|
|
422
|
+
throw new RefusalError('the local Docker runtime is unavailable');
|
|
423
|
+
if (!versionAtLeast(runtime.composeVersion, input.composeFloor))
|
|
424
|
+
throw new RefusalError('the required Compose version is unavailable');
|
|
425
|
+
runtimeVersion = runtime.runtime.version;
|
|
426
|
+
composeVersion = runtime.composeVersion;
|
|
427
|
+
pre = await inventory(execute.run, 'pre', probeScope);
|
|
428
|
+
if (!pre || Object.values(pre).some((ids) => ids.length > 0))
|
|
429
|
+
fail('the disposable namespace collided with pre-existing runtime state');
|
|
430
|
+
collisionFree = true;
|
|
431
|
+
const image = decode(await execute.run('image-precondition', ['image', 'inspect', input.image.reference, '--format', '{{.Id}}']));
|
|
432
|
+
checks.imagePreexisting = image === input.image.id;
|
|
433
|
+
if (!checks.imagePreexisting)
|
|
434
|
+
fail('the exact pre-existing probe image is unavailable; the probe does not pull or build');
|
|
435
|
+
const portBase = 40_000 + Number.parseInt(batchId.slice(0, 4), 16) % 20_000;
|
|
436
|
+
const configSource = [
|
|
437
|
+
'[cockpit]', `name = ${JSON.stringify(projectName)}`, 'siblings_root = "siblings"', '',
|
|
438
|
+
'[[sibling]]', 'slug = "probe"', 'dir = "probe"', 'mount = "always"', 'acquire = "none"',
|
|
439
|
+
'var = "PROBE_DIR"', 'toolchain = "none"', 'purpose = "Synthetic runtime probe"', '',
|
|
440
|
+
'[ports]', 'step = 1', 'count = 1', `bases = { PROBE_PORT = ${portBase} }`, '',
|
|
441
|
+
'[environment]', `compose_files = [${JSON.stringify(basename(candidatePath))}]`,
|
|
442
|
+
'addressing = "port"', 'authentication = "bypassed"',
|
|
443
|
+
'posture_requirements = [{ requirement = "local-runtime-endpoint", addressing = "port" }, { requirement = "local-runtime-access", authentication = "bypassed" }]',
|
|
444
|
+
`resettable_volumes = [${JSON.stringify(PROBE_VOLUME_KEY)}]`, `generated_host_paths = [${JSON.stringify(generatedName)}]`, '',
|
|
445
|
+
'[environment.machine]', 'runtime_memory_mb = 1', 'runtime_cpus = 1', 'free_disk_mb = 1', 'cold_start_seconds = 30', '',
|
|
446
|
+
].join('\n');
|
|
447
|
+
await writeExclusive(configPath, configSource);
|
|
448
|
+
// The lifecycle owners address the context checked above by name, so a `docker context use`
|
|
449
|
+
// during the probe cannot move its stack to another daemon.
|
|
450
|
+
lifecycleEnvironment = { PATH: input.env.PATH ?? '', HOME: homePath,
|
|
451
|
+
...(dockerHost === undefined ? { DOCKER_CONTEXT: dockerContext } : { DOCKER_HOST: dockerHost }) };
|
|
452
|
+
const operationEnvironment = lifecycleEnvironment;
|
|
453
|
+
const freshLifecycle = () => createLifecycleCommandAdapters({
|
|
454
|
+
configPath, env: operationEnvironment, sessionId: `runtime-probe-${batchId}`, timeoutMs: Math.min(input.timeoutMs, 30_000),
|
|
455
|
+
activeProfiles: ['runtime_probe'],
|
|
456
|
+
// A resume reads runtime readiness more than once, and each authority admits one reading.
|
|
457
|
+
runtimeMeasurementProbeAuthority: () => issueRuntimeMeasurementProbeAuthority(runtime),
|
|
458
|
+
}, ownerExecute);
|
|
459
|
+
const lifecycle = freshLifecycle();
|
|
460
|
+
const loadedContext = await lifecycle.load({ configPath });
|
|
461
|
+
if (loadedContext.kind !== 'complete' || loadedContext.result === null) {
|
|
462
|
+
const diagnostic = loadedContext.diagnostics[0];
|
|
463
|
+
throw new RefusalError(diagnostic
|
|
464
|
+
? `the production lifecycle owner refused the disposable checkout (${diagnostic.code}: ${diagnostic.message})`
|
|
465
|
+
: 'the production lifecycle owner could not authenticate the disposable checkout');
|
|
466
|
+
}
|
|
467
|
+
const context = loadedContext.result;
|
|
468
|
+
preClaims = await readActiveStackInventory(context.namespace);
|
|
469
|
+
preLease = await readState(context.namespace.paths.coordination(context.stackId));
|
|
470
|
+
// The candidate carries the same ownership set the Compose override gives a stack. Reclaim
|
|
471
|
+
// admits a resource as current only with stack, config and owner labels, and reset admits a
|
|
472
|
+
// data-bearing volume only with a generation; without them the probe measured a reset
|
|
473
|
+
// refusal that no real stack produces.
|
|
474
|
+
const labels = {
|
|
475
|
+
'dev.b0dea.cockpit.stack-id': context.stackId,
|
|
476
|
+
'dev.b0dea.cockpit.config-sha256': context.runtimeSha256,
|
|
477
|
+
'dev.b0dea.cockpit.owner-id': context.cockpitId,
|
|
478
|
+
'dev.b0dea.cockpit.generation-id': sha256Hex(`compose-volume-generation\0${context.runtimeSha256}`),
|
|
479
|
+
[ownerLabelName]: batchId,
|
|
480
|
+
[PROBE_OWNER_LABEL]: input.owner,
|
|
481
|
+
};
|
|
482
|
+
ownerCleanup = async () => {
|
|
483
|
+
ownerCleanupAttempted = true;
|
|
484
|
+
const destructive = createDestructiveCommandAdapters({
|
|
485
|
+
configPath, env: lifecycleEnvironment, sessionId: `runtime-probe-${batchId}`,
|
|
486
|
+
timeoutMs: Math.min(input.timeoutMs, 10_000), dockerContext,
|
|
487
|
+
activeProfiles: ['runtime_probe'],
|
|
488
|
+
runtimeMeasurementProbeAuthority: () => issueRuntimeMeasurementProbeAuthority(runtime),
|
|
489
|
+
}, cleanupOwnerExecute);
|
|
490
|
+
const down = await runDown({ configPath }, destructive);
|
|
491
|
+
noteRefusal('down', down);
|
|
492
|
+
// `down` removes the stack's runtime resources and keeps its declared data, which only
|
|
493
|
+
// an approved `reset` removes: the resettable volume and the generated host path must
|
|
494
|
+
// both survive it. The probe then removes that data itself, as its own disposable state.
|
|
495
|
+
const left = down.kind === 'complete' ? await inventory(cleanupExecute.run, 'after-down', probeScope) : undefined;
|
|
496
|
+
checks.teardown = left !== undefined && left.containers.length === 0 && left.networks.length === 0;
|
|
497
|
+
checks.configuredHostPathTeardown = left !== undefined && JSON.stringify(left.volumes) === JSON.stringify([volumeName])
|
|
498
|
+
&& await readFile(join(generatedPath, 'owned'), 'utf8').then((owned) => owned === `${marker}\n`, () => false);
|
|
499
|
+
if (left !== undefined && left.volumes.length > 0)
|
|
500
|
+
await exactCleanup(cleanupExecute.run, { ...left, containers: [], networks: [] });
|
|
501
|
+
const [postClaims, postLease] = await Promise.all([
|
|
502
|
+
readActiveStackInventory(context.namespace),
|
|
503
|
+
readState(context.namespace.paths.coordination(context.stackId)),
|
|
504
|
+
]);
|
|
505
|
+
if (preClaims !== undefined && preLease !== undefined) {
|
|
506
|
+
({ claimsEqual, leasesEqual } = currentCoordinationCleanupVerdict(preClaims, postClaims, preLease, postLease, context.stackId));
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const candidateLabels = {
|
|
510
|
+
...labels,
|
|
511
|
+
'dev.b0dea.cockpit.owner-id': '${COCKPIT_OWNER_ID:?cockpit runtime owner identity is unavailable}',
|
|
512
|
+
'dev.b0dea.cockpit.stack-id': '${COCKPIT_STACK_ID:?cockpit runtime stack identity is unavailable}',
|
|
513
|
+
};
|
|
514
|
+
const labelLines = Object.entries(candidateLabels).map(([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value)}`);
|
|
515
|
+
candidateSource = [
|
|
516
|
+
'services:', ' probe:', ` image: ${JSON.stringify(input.image.reference)}`,
|
|
517
|
+
` command: ["sh", "-c", ${JSON.stringify(`printf '%s\\n' '${marker}'; printf '%s\\n' '${marker}' > /generated/owned; while :; do sleep 3600; done`)}]`,
|
|
518
|
+
' environment: ["DEFAULT_ENV_CANARY=${DEFAULT_ENV_CANARY:-declared}"]',
|
|
519
|
+
` env_file: [${JSON.stringify(serviceEnvironmentPath)}]`,
|
|
520
|
+
' labels:', ...labelLines,
|
|
521
|
+
' healthcheck:', ' test: ["CMD", "sh", "-c", "test \\"$$DEFAULT_ENV_CANARY\\" = declared && test \\"$$SERVICE_ENV_CANARY\\" = service-file && test -f /probe/input"]', ' interval: 1s', ' timeout: 1s', ' retries: 10',
|
|
522
|
+
' volumes:',
|
|
523
|
+
` - type: bind`, ` source: ${JSON.stringify(siblingPath)}`, ' target: /probe',
|
|
524
|
+
` - type: bind`, ` source: ${JSON.stringify(generatedPath)}`, ' target: /generated',
|
|
525
|
+
' - type: volume', ` source: ${PROBE_VOLUME_KEY}`, ' target: /data',
|
|
526
|
+
' networks: [probe_net]',
|
|
527
|
+
' profile_control:', ` image: ${JSON.stringify(input.image.reference)}`, ' command: ["sh", "-c", "while :; do sleep 3600; done"]',
|
|
528
|
+
' profiles: ["runtime_probe"]', ' networks: [probe_net]', ' labels:', ...labelLines,
|
|
529
|
+
' healthcheck:', ' test: ["CMD", "sh", "-c", "kill -0 1"]', ' interval: 1s', ' timeout: 1s', ' retries: 10',
|
|
530
|
+
'volumes:', ` ${PROBE_VOLUME_KEY}:`, ` name: ${JSON.stringify(volumeName)}`, ' labels:', ...labelLines,
|
|
531
|
+
'networks:', ' probe_net:', ` name: ${JSON.stringify(`${projectName}-network`)}`, ' labels:', ...labelLines, '',
|
|
532
|
+
].join('\n');
|
|
533
|
+
candidateArtifact = { path: candidatePath, sha256: sha256Hex(candidateSource) };
|
|
534
|
+
await writeFile(defaultEnvironmentPath, 'DEFAULT_ENV_CANARY=unauthenticated\n', { mode: 0o600 });
|
|
535
|
+
await writeFile(serviceEnvironmentPath, 'SERVICE_ENV_CANARY=service-file\n', { mode: 0o600 });
|
|
536
|
+
await writeFile(candidatePath, candidateSource, { mode: 0o600 });
|
|
537
|
+
// A compose_files stack runs with the ownership override `sync` publishes; the probe writes
|
|
538
|
+
// the same production render instead of running sync in its disposable checkout.
|
|
539
|
+
const ownership = renderComposeOwnership({
|
|
540
|
+
sources: [{ reference: basename(candidatePath), source: candidateSource }],
|
|
541
|
+
stackId: context.stackId, cockpitId: context.cockpitId, configSha256: context.runtimeSha256,
|
|
542
|
+
resettableVolumes: [PROBE_VOLUME_KEY],
|
|
543
|
+
});
|
|
544
|
+
if (ownership.kind !== 'complete')
|
|
545
|
+
fail(`the candidate's ownership override could not be rendered (${ownership.reason})`);
|
|
546
|
+
await writeExclusive(ownershipPath, ownership.bytes);
|
|
547
|
+
fresh();
|
|
548
|
+
const control = await execute.run('broken-control', [...composeArgs(input, projectName, controlPath), 'config', '--quiet'], controlArtifact);
|
|
549
|
+
if (control.kind === 'complete')
|
|
550
|
+
brokenControl = 'accepted';
|
|
551
|
+
else if (control.execution.started && control.execution.signal === null && control.code === ENVIRONMENT_CODES.EXECUTION_STEP_FAILED)
|
|
552
|
+
brokenControl = 'rejected';
|
|
553
|
+
if (brokenControl !== 'rejected')
|
|
554
|
+
fail('the deliberately broken adjacent control was accepted or inconclusive');
|
|
555
|
+
const topology = await execute.run('profile-topology', [
|
|
556
|
+
...composeArgs(input, projectName, candidatePath), '--profile', 'runtime_probe',
|
|
557
|
+
'config', '--format', 'json', '--no-interpolate', '--no-env-resolution',
|
|
558
|
+
], candidateArtifact);
|
|
559
|
+
const topologyText = decode(topology);
|
|
560
|
+
if (topologyText === undefined)
|
|
561
|
+
throw new RefusalError(`the exact topology projection failed (${refusalCategory(topology)}: ${generatedArtifactDetail(topology)})`);
|
|
562
|
+
try {
|
|
563
|
+
const parsed = JSON.parse(topologyText);
|
|
564
|
+
profileProjected = Array.isArray(parsed.services?.profile_control?.profiles)
|
|
565
|
+
&& parsed.services.profile_control.profiles.includes('runtime_probe');
|
|
566
|
+
const probe = parsed.services?.probe;
|
|
567
|
+
checks.topology = probe !== undefined && Array.isArray(probe.volumes) && probe.volumes.length === 3
|
|
568
|
+
&& probe.healthcheck !== undefined && probe.labels !== undefined;
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
fail(`the topology projection was not readable JSON: ${errorCause(error)}`);
|
|
572
|
+
}
|
|
573
|
+
if (!profileProjected || !checks.topology)
|
|
574
|
+
fail('the candidate did not project its profile, mounts, labels, and healthcheck');
|
|
575
|
+
fresh();
|
|
576
|
+
const up = await runUp({ configPath }, lifecycle);
|
|
577
|
+
checks.applicationUp = up.kind === 'complete';
|
|
578
|
+
if (!checks.applicationUp) {
|
|
579
|
+
const diagnostics = up.diagnostics.map(({ code, message }) => `${code}: ${message}`).join('; ');
|
|
580
|
+
const stages = Object.entries(ownerStages).map(([id, outcome]) => `${id}=${outcome}`).join(',');
|
|
581
|
+
fail(diagnostics.length > 0
|
|
582
|
+
? `the application lifecycle owner refused the live candidate (${diagnostics}; stages=${stages})`
|
|
583
|
+
: 'the application lifecycle owner did not start the live candidate');
|
|
584
|
+
}
|
|
585
|
+
const owned = await inventory(execute.run, 'created', [ownerLabel]);
|
|
586
|
+
checks.ownership = owned !== undefined && owned.containers.length === 2 && owned.volumes.length === 1 && owned.networks.length === 1;
|
|
587
|
+
if (!checks.ownership)
|
|
588
|
+
fail('created identities did not carry the complete ownership label');
|
|
589
|
+
const containerId = decode(await execute.run('probe-container', [
|
|
590
|
+
'ps', '-aq', '--filter', `label=${ownerLabel}`, '--filter', 'label=com.docker.compose.service=probe',
|
|
591
|
+
]));
|
|
592
|
+
if (!containerId || !owned.containers.includes(containerId))
|
|
593
|
+
throw new RefusalError('the live probe container identity was not uniquely authenticated');
|
|
594
|
+
const inspected = decode(await execute.run('inspect', ['container', 'inspect', containerId, '--format', '{{json .}}']));
|
|
595
|
+
if (inspected === undefined)
|
|
596
|
+
throw new RefusalError('the created container identity could not be inspected');
|
|
597
|
+
const profileContainerId = decode(await execute.run('profile-container', [
|
|
598
|
+
'ps', '-aq', '--filter', `label=${ownerLabel}`, '--filter', 'label=com.docker.compose.service=profile_control',
|
|
599
|
+
]));
|
|
600
|
+
if (!profileContainerId || !owned.containers.includes(profileContainerId))
|
|
601
|
+
throw new RefusalError('the activated profile container identity was not uniquely authenticated');
|
|
602
|
+
const profileInspected = decode(await execute.run('profile-inspect', ['container', 'inspect', profileContainerId, '--format', '{{json .}}']));
|
|
603
|
+
if (profileInspected === undefined)
|
|
604
|
+
throw new RefusalError('the activated profile container identity could not be inspected');
|
|
605
|
+
try {
|
|
606
|
+
const data = JSON.parse(inspected);
|
|
607
|
+
const profileData = JSON.parse(profileInspected);
|
|
608
|
+
checks.health = data.State?.Health?.Status === 'healthy';
|
|
609
|
+
checks.profile = profileProjected && profileData.State?.Status === 'running'
|
|
610
|
+
&& Object.entries(labels).every(([name, value]) => profileData.Config?.Labels?.[name] === value);
|
|
611
|
+
checks.ownership = checks.ownership && Object.entries(labels).every(([name, value]) => data.Config?.Labels?.[name] === value)
|
|
612
|
+
&& [siblingPath, generatedPath].every((source) => data.Mounts?.some((mount) => mount.Source === source) === true);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
fail(`the container inspection was malformed: ${errorCause(error)}`);
|
|
616
|
+
}
|
|
617
|
+
if (!checks.profile || !checks.health || !checks.ownership)
|
|
618
|
+
fail('profile activation, health, mounts, or canonical ownership labels were not closed');
|
|
619
|
+
const readMarker = decode(await execute.run('mount-read', ['container', 'exec', containerId, 'cat', '/probe/input']));
|
|
620
|
+
checks.mountHostToContainer = readMarker === marker;
|
|
621
|
+
const writeMarker = await execute.run('mount-write', [
|
|
622
|
+
'container', 'exec', containerId, 'sh', '-c', `printf '%s\\n' '${marker}' > /probe/output`,
|
|
623
|
+
]);
|
|
624
|
+
checks.mountContainerToHost = writeMarker.kind === 'complete' && (await readFile(outputMarkerPath, 'utf8').catch(() => '')) === `${marker}\n`;
|
|
625
|
+
if (!checks.mountHostToContainer || !checks.mountContainerToHost)
|
|
626
|
+
fail('live bind-mount byte movement was not proven in both directions');
|
|
627
|
+
const read = createReadCommandAdapters({
|
|
628
|
+
configPath, env: lifecycleEnvironment, sessionId: `runtime-probe-${batchId}`,
|
|
629
|
+
timeoutMs: Math.min(input.timeoutMs, 30_000),
|
|
630
|
+
runtimeMeasurementProbeAuthority: issueRuntimeMeasurementProbeAuthority(runtime),
|
|
631
|
+
}, ownerExecute);
|
|
632
|
+
const logResult = await runLogs({ configPath, service: 'probe', since: '10m', tail: 20, follow: false, json: false }, read.logs);
|
|
633
|
+
checks.logs = logResult.kind === 'complete' && logResult.result.records.some(({ message }) => message.includes(marker));
|
|
634
|
+
if (!checks.logs)
|
|
635
|
+
fail('bounded non-follow logs did not carry the live marker');
|
|
636
|
+
const slept = await runSleep({ configPath }, freshLifecycle());
|
|
637
|
+
const resumed = slept.kind === 'complete' ? await runUp({ configPath }, freshLifecycle()) : slept;
|
|
638
|
+
checks.recovery = slept.kind === 'complete' && resumed.kind === 'complete';
|
|
639
|
+
if (!checks.recovery) {
|
|
640
|
+
const refused = slept.kind === 'complete' ? resumed : slept;
|
|
641
|
+
const diagnostics = refused.diagnostics.map(({ code, message }) => `${code}: ${message}`).join('; ');
|
|
642
|
+
fail(`the application lifecycle owner did not close interrupted recovery (${slept.kind === 'complete' ? 'resume' : 'sleep'}`
|
|
643
|
+
+ `${diagnostics.length > 0 ? `: ${diagnostics}` : ''})`);
|
|
644
|
+
}
|
|
645
|
+
const destructive = createDestructiveCommandAdapters({
|
|
646
|
+
configPath, env: lifecycleEnvironment, sessionId: `runtime-probe-${batchId}`,
|
|
647
|
+
timeoutMs: Math.min(input.timeoutMs, 30_000), dockerContext,
|
|
648
|
+
activeProfiles: ['runtime_probe'],
|
|
649
|
+
runtimeMeasurementProbeAuthority: () => issueRuntimeMeasurementProbeAuthority(runtime),
|
|
650
|
+
}, ownerExecute);
|
|
651
|
+
const reset = await runReset({ configPath }, destructive);
|
|
652
|
+
checks.resetPreview = reset.kind === 'preview' && !reset.result.noop;
|
|
653
|
+
noteRefusal('reset preview', reset);
|
|
654
|
+
await ownerCleanup();
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
failureReason = error instanceof RefusalError ? error.message : 'the installed runtime cell threw before it could close';
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
if (collisionFree) {
|
|
661
|
+
try {
|
|
662
|
+
if (ownerCleanup && !ownerCleanupAttempted) {
|
|
663
|
+
try {
|
|
664
|
+
await ownerCleanup();
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
checks.teardown = false; /* reported as the teardown verdict */
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
let survivors = await inventory(cleanupExecute.run, 'post-owned', probeScope);
|
|
671
|
+
for (let attempt = 0; attempt < 2 && survivors
|
|
672
|
+
&& ['containers', 'volumes', 'networks'].some((kind) => survivors[kind].length > 0); attempt += 1) {
|
|
673
|
+
checks.teardown = false;
|
|
674
|
+
await exactCleanup(cleanupExecute.run, survivors);
|
|
675
|
+
survivors = await inventory(cleanupExecute.run, `post-fallback-${attempt + 1}`, probeScope);
|
|
676
|
+
}
|
|
677
|
+
ownedSurvivors = (survivors === undefined ? ['inventory-unavailable']
|
|
678
|
+
: Object.entries(survivors).flatMap(([kind, ids]) => kind === 'images' ? [] : ids.map((id) => `${kind}:${id}`)).sort());
|
|
679
|
+
const post = pre === undefined ? undefined : await inventory(cleanupExecute.run, 'post', probeScope);
|
|
680
|
+
resourcesEqual = pre !== undefined && post !== undefined && inventoriesEqual(pre, post);
|
|
681
|
+
changedResourceKinds = pre !== undefined && post !== undefined ? inventoryDifferences(pre, post) : ['inventory-unavailable'];
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Cleanup is best effort; the teardown verdict and survivor list report the failure.
|
|
685
|
+
checks.teardown = false;
|
|
686
|
+
ownedSurvivors = ['cleanup-unavailable'];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
for (const path of [configPath, candidatePath, controlPath, defaultEnvironmentPath, ownershipPath,
|
|
690
|
+
generatedPath, siblingsRoot, homePath, join(input.cwd, '.git')]) {
|
|
691
|
+
try {
|
|
692
|
+
await rm(path, { recursive: true, force: true });
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
checks.teardown = false; /* reported as the teardown verdict */
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const finishedAt = new Date().toISOString();
|
|
700
|
+
const evidence = {
|
|
701
|
+
batchId,
|
|
702
|
+
installedModuleSha256: input.installedModule.sha256, runtimeExecutableSha256: input.executable.sha256,
|
|
703
|
+
candidateSha256: candidateArtifact.sha256, brokenControlSha256: controlArtifact.sha256,
|
|
704
|
+
projectName, runtimePath: input.executable.canonicalPath, runtimeVersion: runtimeVersion ?? null, composeVersion: composeVersion ?? null,
|
|
705
|
+
operatingSystem: platform(), architecture: arch(), startedAt, finishedAt, expiresAt,
|
|
706
|
+
candidateChecks: { ...checks }, brokenControlRejected: brokenControl === 'rejected',
|
|
707
|
+
cleanup: { ownedSurvivors, resourcesEqual, claimsEqual, leasesEqual },
|
|
708
|
+
};
|
|
709
|
+
const complete = runtimeVersion !== undefined && composeVersion !== undefined && brokenControl === 'rejected'
|
|
710
|
+
&& failureReason === undefined && Date.parse(finishedAt) <= Date.parse(expiresAt) && Object.values(checks).every(Boolean)
|
|
711
|
+
&& ownedSurvivors.length === 0 && resourcesEqual && claimsEqual && leasesEqual;
|
|
712
|
+
if (!complete) {
|
|
713
|
+
const missing = Object.entries(checks).filter(([, passed]) => !passed).map(([name]) => name).join(',') || 'none';
|
|
714
|
+
const reason = failureReason ?? `the installed runtime cell remained incomplete (checks=${missing}; survivors=${ownedSurvivors.length}; changedResources=${changedResourceKinds.join(',') || 'none'}${refusals.length > 0 ? `; ${refusals.join('; ')}` : ''})`;
|
|
715
|
+
return unavailable(input, reason, execute.timedOut() || cleanupExecute.timedOut() || ownerTimedOut, evidence, brokenControl);
|
|
716
|
+
}
|
|
717
|
+
const supportedRuntimeVersion = runtimeVersion;
|
|
718
|
+
const supportedComposeVersion = composeVersion;
|
|
719
|
+
if (supportedRuntimeVersion === undefined || supportedComposeVersion === undefined)
|
|
720
|
+
return unavailable(input, 'runtime identity was not retained', execute.timedOut() || cleanupExecute.timedOut() || ownerTimedOut, evidence, brokenControl);
|
|
721
|
+
return {
|
|
722
|
+
status: 'supported', runtime: { name: 'Docker Engine', version: supportedRuntimeVersion }, composeVersion: supportedComposeVersion,
|
|
723
|
+
composeFloor: input.composeFloor, measuredOn: input.measuredOn, candidate: 'passed', brokenControl: 'rejected', evidence, operatorAction: null,
|
|
724
|
+
};
|
|
725
|
+
}
|