@b00dea/cockpit 0.0.0-stage → 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.
Files changed (363) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +22 -2
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2574 -0
  6. package/dist/autonomy.d.ts +44 -0
  7. package/dist/autonomy.js +156 -0
  8. package/dist/bounded-read.d.ts +14 -0
  9. package/dist/bounded-read.js +42 -0
  10. package/dist/browser/commands.d.ts +62 -0
  11. package/dist/browser/commands.js +349 -0
  12. package/dist/browser/package-paths.d.ts +1 -0
  13. package/dist/browser/package-paths.js +6 -0
  14. package/dist/browser/proxy.d.ts +22 -0
  15. package/dist/browser/proxy.js +37 -0
  16. package/dist/browser/runtime.d.ts +117 -0
  17. package/dist/browser/runtime.js +183 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +556 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/cli-guard.d.ts +5 -0
  23. package/dist/cli-guard.js +71 -0
  24. package/dist/cli-main.d.ts +139 -0
  25. package/dist/cli-main.js +1405 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.js +16 -0
  28. package/dist/command-table.d.ts +113 -0
  29. package/dist/command-table.js +512 -0
  30. package/dist/config/load.d.ts +171 -0
  31. package/dist/config/load.js +345 -0
  32. package/dist/config/sibling-toml.d.ts +48 -0
  33. package/dist/config/sibling-toml.js +160 -0
  34. package/dist/config/surface-tools-toml.d.ts +9 -0
  35. package/dist/config/surface-tools-toml.js +37 -0
  36. package/dist/config/verified-write.d.ts +43 -0
  37. package/dist/config/verified-write.js +89 -0
  38. package/dist/diagnostics.d.ts +26 -0
  39. package/dist/diagnostics.js +335 -0
  40. package/dist/emit.d.ts +22 -0
  41. package/dist/emit.js +43 -0
  42. package/dist/entrypoint-kinds.d.ts +3 -0
  43. package/dist/entrypoint-kinds.js +11 -0
  44. package/dist/environment/application/destructive.d.ts +6 -0
  45. package/dist/environment/application/destructive.js +425 -0
  46. package/dist/environment/application/doctor.d.ts +89 -0
  47. package/dist/environment/application/doctor.js +235 -0
  48. package/dist/environment/application/guard.d.ts +43 -0
  49. package/dist/environment/application/guard.js +622 -0
  50. package/dist/environment/application/health.d.ts +134 -0
  51. package/dist/environment/application/health.js +379 -0
  52. package/dist/environment/application/lifecycle-context.d.ts +228 -0
  53. package/dist/environment/application/lifecycle-context.js +154 -0
  54. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  55. package/dist/environment/application/lifecycle-state.js +201 -0
  56. package/dist/environment/application/lifecycle.d.ts +17 -0
  57. package/dist/environment/application/lifecycle.js +729 -0
  58. package/dist/environment/application/logs.d.ts +45 -0
  59. package/dist/environment/application/logs.js +127 -0
  60. package/dist/environment/application/setup.d.ts +129 -0
  61. package/dist/environment/application/setup.js +741 -0
  62. package/dist/environment/application/shared.d.ts +11 -0
  63. package/dist/environment/application/shared.js +61 -0
  64. package/dist/environment/claims.d.ts +389 -0
  65. package/dist/environment/claims.js +1709 -0
  66. package/dist/environment/codex-hook-trust.d.ts +11 -0
  67. package/dist/environment/codex-hook-trust.js +185 -0
  68. package/dist/environment/compose-environment.d.ts +1 -0
  69. package/dist/environment/compose-environment.js +6 -0
  70. package/dist/environment/compose-ownership.d.ts +21 -0
  71. package/dist/environment/compose-ownership.js +117 -0
  72. package/dist/environment/compose-topology.d.ts +117 -0
  73. package/dist/environment/compose-topology.js +413 -0
  74. package/dist/environment/contract.d.ts +489 -0
  75. package/dist/environment/contract.js +761 -0
  76. package/dist/environment/declaration.d.ts +14 -0
  77. package/dist/environment/declaration.js +39 -0
  78. package/dist/environment/destructive-composition.d.ts +29 -0
  79. package/dist/environment/destructive-composition.js +333 -0
  80. package/dist/environment/docker-daemon.d.ts +30 -0
  81. package/dist/environment/docker-daemon.js +78 -0
  82. package/dist/environment/docker-endpoint.d.ts +10 -0
  83. package/dist/environment/docker-endpoint.js +19 -0
  84. package/dist/environment/env-schema-observation.d.ts +46 -0
  85. package/dist/environment/env-schema-observation.js +241 -0
  86. package/dist/environment/evidence.d.ts +136 -0
  87. package/dist/environment/evidence.js +437 -0
  88. package/dist/environment/executable.d.ts +26 -0
  89. package/dist/environment/executable.js +167 -0
  90. package/dist/environment/generated-application/authorities.d.ts +5 -0
  91. package/dist/environment/generated-application/authorities.js +61 -0
  92. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  93. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  94. package/dist/environment/generated-application/check.d.ts +13 -0
  95. package/dist/environment/generated-application/check.js +189 -0
  96. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  97. package/dist/environment/generated-application/custom-surface.js +289 -0
  98. package/dist/environment/generated-application/prepare.d.ts +29 -0
  99. package/dist/environment/generated-application/prepare.js +276 -0
  100. package/dist/environment/generated-application/publication.d.ts +33 -0
  101. package/dist/environment/generated-application/publication.js +547 -0
  102. package/dist/environment/generated-application/shared.d.ts +484 -0
  103. package/dist/environment/generated-application/shared.js +349 -0
  104. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  105. package/dist/environment/generated-application/surface-observation.js +69 -0
  106. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  107. package/dist/environment/generated-application/surface-outputs.js +389 -0
  108. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  109. package/dist/environment/generated-application/surfaces.js +699 -0
  110. package/dist/environment/generated-application/sync.d.ts +6 -0
  111. package/dist/environment/generated-application/sync.js +109 -0
  112. package/dist/environment/git-observation.d.ts +183 -0
  113. package/dist/environment/git-observation.js +544 -0
  114. package/dist/environment/health-composition.d.ts +11 -0
  115. package/dist/environment/health-composition.js +628 -0
  116. package/dist/environment/host-observation.d.ts +56 -0
  117. package/dist/environment/host-observation.js +52 -0
  118. package/dist/environment/identity.d.ts +48 -0
  119. package/dist/environment/identity.js +85 -0
  120. package/dist/environment/init-application.d.ts +308 -0
  121. package/dist/environment/init-application.js +697 -0
  122. package/dist/environment/install-policy-observation.d.ts +71 -0
  123. package/dist/environment/install-policy-observation.js +284 -0
  124. package/dist/environment/install-policy.d.ts +48 -0
  125. package/dist/environment/install-policy.js +96 -0
  126. package/dist/environment/instance.d.ts +33 -0
  127. package/dist/environment/instance.js +122 -0
  128. package/dist/environment/instruction-pointer.d.ts +19 -0
  129. package/dist/environment/instruction-pointer.js +144 -0
  130. package/dist/environment/lifecycle-composition.d.ts +29 -0
  131. package/dist/environment/lifecycle-composition.js +675 -0
  132. package/dist/environment/lifecycle-stage.d.ts +115 -0
  133. package/dist/environment/lifecycle-stage.js +398 -0
  134. package/dist/environment/lifecycle.d.ts +75 -0
  135. package/dist/environment/lifecycle.js +354 -0
  136. package/dist/environment/logs.d.ts +132 -0
  137. package/dist/environment/logs.js +519 -0
  138. package/dist/environment/observations.d.ts +46 -0
  139. package/dist/environment/observations.js +344 -0
  140. package/dist/environment/operator-application.d.ts +50 -0
  141. package/dist/environment/operator-application.js +155 -0
  142. package/dist/environment/ports.d.ts +182 -0
  143. package/dist/environment/ports.js +1205 -0
  144. package/dist/environment/posture-observation.d.ts +68 -0
  145. package/dist/environment/posture-observation.js +253 -0
  146. package/dist/environment/read-composition.d.ts +27 -0
  147. package/dist/environment/read-composition.js +352 -0
  148. package/dist/environment/readiness-composition.d.ts +38 -0
  149. package/dist/environment/readiness-composition.js +130 -0
  150. package/dist/environment/reclaim.d.ts +333 -0
  151. package/dist/environment/reclaim.js +1835 -0
  152. package/dist/environment/repository-identity.d.ts +13 -0
  153. package/dist/environment/repository-identity.js +108 -0
  154. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  155. package/dist/environment/runtime-measurement-record.js +111 -0
  156. package/dist/environment/runtime-measurement.d.ts +27 -0
  157. package/dist/environment/runtime-measurement.js +224 -0
  158. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  159. package/dist/environment/runtime-probe-residue.js +148 -0
  160. package/dist/environment/runtime-probe.d.ts +72 -0
  161. package/dist/environment/runtime-probe.js +725 -0
  162. package/dist/environment/runtime.d.ts +505 -0
  163. package/dist/environment/runtime.js +2335 -0
  164. package/dist/environment/seed.d.ts +16 -0
  165. package/dist/environment/seed.js +43 -0
  166. package/dist/environment/selftest-application.d.ts +86 -0
  167. package/dist/environment/selftest-application.js +713 -0
  168. package/dist/environment/sensitive.d.ts +3 -0
  169. package/dist/environment/sensitive.js +48 -0
  170. package/dist/environment/setup-observation.d.ts +62 -0
  171. package/dist/environment/setup-observation.js +1473 -0
  172. package/dist/environment/setup.d.ts +43 -0
  173. package/dist/environment/setup.js +176 -0
  174. package/dist/environment/sibling-instructions.d.ts +33 -0
  175. package/dist/environment/sibling-instructions.js +224 -0
  176. package/dist/environment/sibling-registration.d.ts +45 -0
  177. package/dist/environment/sibling-registration.js +538 -0
  178. package/dist/environment/state.d.ts +371 -0
  179. package/dist/environment/state.js +2013 -0
  180. package/dist/environment/templates.d.ts +140 -0
  181. package/dist/environment/templates.js +685 -0
  182. package/dist/environment/tool-registration.d.ts +9 -0
  183. package/dist/environment/tool-registration.js +96 -0
  184. package/dist/environment/upgrade-application.d.ts +19 -0
  185. package/dist/environment/upgrade-application.js +175 -0
  186. package/dist/environment-exec/execute.d.ts +102 -0
  187. package/dist/environment-exec/execute.js +1175 -0
  188. package/dist/environment-exec/libexec-program.d.ts +6 -0
  189. package/dist/environment-exec/libexec-program.js +22 -0
  190. package/dist/environment-exec/verified-digest.d.ts +4 -0
  191. package/dist/environment-exec/verified-digest.js +16 -0
  192. package/dist/exact-keys.d.ts +7 -0
  193. package/dist/exact-keys.js +15 -0
  194. package/dist/fix-catalogue.d.ts +32 -0
  195. package/dist/fix-catalogue.js +60 -0
  196. package/dist/freshness.d.ts +29 -0
  197. package/dist/freshness.js +75 -0
  198. package/dist/generated-environment/adopt.d.ts +16 -0
  199. package/dist/generated-environment/adopt.js +275 -0
  200. package/dist/generated-environment/command.d.ts +47 -0
  201. package/dist/generated-environment/command.js +75 -0
  202. package/dist/generated-environment/transaction.d.ts +186 -0
  203. package/dist/generated-environment/transaction.js +1770 -0
  204. package/dist/grading/evaluate.d.ts +71 -0
  205. package/dist/grading/evaluate.js +403 -0
  206. package/dist/grading/grade.d.ts +33 -0
  207. package/dist/grading/grade.js +57 -0
  208. package/dist/guards/evaluator.d.ts +113 -0
  209. package/dist/guards/evaluator.js +1226 -0
  210. package/dist/guards/harness-adapter.d.ts +17 -0
  211. package/dist/guards/harness-adapter.js +57 -0
  212. package/dist/guards/host.d.ts +8 -0
  213. package/dist/guards/host.js +10 -0
  214. package/dist/guards/invocation-parser.d.ts +66 -0
  215. package/dist/guards/invocation-parser.js +595 -0
  216. package/dist/guards/local-identity.d.ts +40 -0
  217. package/dist/guards/local-identity.js +123 -0
  218. package/dist/guards/policy-pin.d.ts +13 -0
  219. package/dist/guards/policy-pin.js +175 -0
  220. package/dist/guards/protected-locations.d.ts +62 -0
  221. package/dist/guards/protected-locations.js +461 -0
  222. package/dist/guards/read-only.d.ts +37 -0
  223. package/dist/guards/read-only.js +315 -0
  224. package/dist/init/command.d.ts +40 -0
  225. package/dist/init/command.js +310 -0
  226. package/dist/init/derive.d.ts +51 -0
  227. package/dist/init/derive.js +715 -0
  228. package/dist/init/draft.d.ts +138 -0
  229. package/dist/init/draft.js +1205 -0
  230. package/dist/init/render.d.ts +35 -0
  231. package/dist/init/render.js +82 -0
  232. package/dist/init/scaffold.d.ts +53 -0
  233. package/dist/init/scaffold.js +121 -0
  234. package/dist/invocation.d.ts +13 -0
  235. package/dist/invocation.js +20 -0
  236. package/dist/package-metadata.d.ts +9 -0
  237. package/dist/package-metadata.js +19 -0
  238. package/dist/readiness.d.ts +53 -0
  239. package/dist/readiness.js +88 -0
  240. package/dist/references/named.d.ts +24 -0
  241. package/dist/references/named.js +27 -0
  242. package/dist/refusal.d.ts +15 -0
  243. package/dist/refusal.js +7 -0
  244. package/dist/registry-derivation.d.ts +31 -0
  245. package/dist/registry-derivation.js +32 -0
  246. package/dist/registry-resolve/command.d.ts +47 -0
  247. package/dist/registry-resolve/command.js +226 -0
  248. package/dist/registry-resolve/registry.d.ts +68 -0
  249. package/dist/registry-resolve/registry.js +292 -0
  250. package/dist/registry-resolve/render-shell.d.ts +18 -0
  251. package/dist/registry-resolve/render-shell.js +36 -0
  252. package/dist/repair.d.ts +29 -0
  253. package/dist/repair.js +8 -0
  254. package/dist/schema-json.d.ts +1 -0
  255. package/dist/schema-json.js +177 -0
  256. package/dist/schema.d.ts +1322 -0
  257. package/dist/schema.js +934 -0
  258. package/dist/setup/command.d.ts +4 -0
  259. package/dist/setup/command.js +4 -0
  260. package/dist/sha256.d.ts +1 -0
  261. package/dist/sha256.js +4 -0
  262. package/dist/surfaces/adoption.d.ts +41 -0
  263. package/dist/surfaces/adoption.js +217 -0
  264. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  265. package/dist/surfaces/consumer-instructions.js +43 -0
  266. package/dist/surfaces/contract.d.ts +42 -0
  267. package/dist/surfaces/contract.js +169 -0
  268. package/dist/surfaces/core-declarations.d.ts +24 -0
  269. package/dist/surfaces/core-declarations.js +79 -0
  270. package/dist/surfaces/development-markers.d.ts +1 -0
  271. package/dist/surfaces/development-markers.js +18 -0
  272. package/dist/surfaces/generator-adapter.d.ts +71 -0
  273. package/dist/surfaces/generator-adapter.js +809 -0
  274. package/dist/surfaces/harness-registry.d.ts +180 -0
  275. package/dist/surfaces/harness-registry.js +117 -0
  276. package/dist/surfaces/mcp.d.ts +95 -0
  277. package/dist/surfaces/mcp.js +186 -0
  278. package/dist/surfaces/merge.d.ts +30 -0
  279. package/dist/surfaces/merge.js +105 -0
  280. package/dist/surfaces/ownership.d.ts +190 -0
  281. package/dist/surfaces/ownership.js +795 -0
  282. package/dist/surfaces/projection.d.ts +136 -0
  283. package/dist/surfaces/projection.js +681 -0
  284. package/dist/surfaces/render.d.ts +118 -0
  285. package/dist/surfaces/render.js +633 -0
  286. package/dist/surfaces/restrictions.d.ts +130 -0
  287. package/dist/surfaces/restrictions.js +447 -0
  288. package/dist/surfaces/selftest.d.ts +162 -0
  289. package/dist/surfaces/selftest.js +393 -0
  290. package/dist/surfaces/skill-contract.d.ts +85 -0
  291. package/dist/surfaces/skill-contract.js +449 -0
  292. package/dist/surfaces/sync.d.ts +57 -0
  293. package/dist/surfaces/sync.js +164 -0
  294. package/dist/team-metadata.d.ts +41 -0
  295. package/dist/team-metadata.js +512 -0
  296. package/dist/testing/gates.d.ts +91 -0
  297. package/dist/testing/gates.js +182 -0
  298. package/dist/testing/hooks.d.ts +59 -0
  299. package/dist/testing/hooks.js +139 -0
  300. package/dist/testing/verdict.d.ts +29 -0
  301. package/dist/testing/verdict.js +62 -0
  302. package/dist/work-orders/application.d.ts +99 -0
  303. package/dist/work-orders/application.js +383 -0
  304. package/dist/work-orders/composition.d.ts +124 -0
  305. package/dist/work-orders/composition.js +1010 -0
  306. package/dist/work-orders/contract.d.ts +61 -0
  307. package/dist/work-orders/contract.js +122 -0
  308. package/dist/work-orders/store.d.ts +158 -0
  309. package/dist/work-orders/store.js +197 -0
  310. package/docs/README.md +65 -0
  311. package/docs/architecture.md +136 -0
  312. package/docs/capability-matrix.md +50 -0
  313. package/docs/cli-reference.md +599 -0
  314. package/docs/command-list.md +51 -0
  315. package/docs/compose-patterns.md +99 -0
  316. package/docs/concepts.md +171 -0
  317. package/docs/configuration-essentials.md +172 -0
  318. package/docs/configuration-reference.md +621 -0
  319. package/docs/environment.md +760 -0
  320. package/docs/extending.md +43 -0
  321. package/docs/faq.md +111 -0
  322. package/docs/getting-started.md +608 -0
  323. package/docs/harness-surfaces.md +433 -0
  324. package/docs/joining-a-team.md +76 -0
  325. package/docs/release-policy.md +49 -0
  326. package/docs/safety-and-autonomy.md +419 -0
  327. package/docs/troubleshooting.md +361 -0
  328. package/docs/tutorial.md +170 -0
  329. package/docs/work-and-browser.md +254 -0
  330. package/libexec/browser-operation.cjs +186 -0
  331. package/libexec/fence-marker.cjs +101 -0
  332. package/libexec/fence-marker.d.cts +24 -0
  333. package/libexec/fenced-group-runner.cjs +77 -0
  334. package/libexec/fenced-supervisor.cjs +137 -0
  335. package/libexec/generated-filesystem.cjs +731 -0
  336. package/libexec/install-filesystem.cjs +225 -0
  337. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  338. package/libexec/rulesync-generator.cjs +743 -0
  339. package/libexec/surface-generator-worker.cjs +266 -0
  340. package/libexec/surface-output-reader.cjs +99 -0
  341. package/libexec/surface-scratch-cleanup.cjs +14 -0
  342. package/libexec/validate-harness-evidence.d.mts +183 -0
  343. package/libexec/validate-harness-evidence.mjs +1312 -0
  344. package/package.json +74 -4
  345. package/surfaces/README.md +33 -0
  346. package/surfaces/agents/evaluator.agent.md +76 -0
  347. package/surfaces/guards/bash-guard.sh +48 -0
  348. package/surfaces/guards/claude.adapter.json +25 -0
  349. package/surfaces/guards/codex.adapter.json +24 -0
  350. package/surfaces/guards/opencode.adapter.json +25 -0
  351. package/surfaces/references/browser.md +63 -0
  352. package/surfaces/references/test-data-and-personas.md +84 -0
  353. package/surfaces/references/testing-tiers.md +11 -0
  354. package/surfaces/skills/cockpit-browser.skill.md +54 -0
  355. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  356. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  357. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  358. package/surfaces/skills/cockpit-work.skill.md +34 -0
  359. package/surfaces/skills/collection.json +5 -0
  360. package/surfaces/skills/procedures/explain.md +61 -0
  361. package/surfaces/skills/procedures/fix.md +81 -0
  362. package/surfaces/skills/procedures/setup.md +379 -0
  363. package/surfaces/skills/procedures/work.md +105 -0
@@ -0,0 +1,2335 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { platform } from 'node:os';
3
+ import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
4
+ import { parseDocument } from 'yaml';
5
+ import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
6
+ import { MAX_COLD_START_SECONDS } from '../schema.js';
7
+ import { consumeMachineFloorConfigProjection } from '../config/load.js';
8
+ import { tryParseJson, RefusalError, DOCKER_CLIENT_ENVIRONMENT, ENVIRONMENT_CODES, errorCause, isCanonicalTimestamp, OBSERVATION_REASON_BYTE_LIMIT, snapshotEnvironmentObservation, HOST_PROCESS_CONTROL_ENV, snapshotExecutionPlan, snapshotExecutionStep, snapshotJson, } from './contract.js';
9
+ import { compareBindSources, composeProjectDirectory } from './compose-topology.js';
10
+ import { isResolvedDockerExecutable, resolvedDockerExecutable, resolvedExecutable } from './executable.js';
11
+ import {} from './claims.js';
12
+ import { stackId as deriveStackId } from './identity.js';
13
+ import { inspectPortLeaseProjection, observePortListeners, revalidatePortLeaseProjection, } from './ports.js';
14
+ import { cockpitCommand } from '../fix-catalogue.js';
15
+ import { consumeSetupCheckoutProjection } from './setup-observation.js';
16
+ import { composeProcessEnvironment } from './compose-environment.js';
17
+ import { exactOwnKeys } from '../exact-keys.js';
18
+ const OWNER = 'runtime-planning';
19
+ export const observationDeclarations = Object.freeze([
20
+ { id: 'runtime-availability', owner: OWNER, postures: ['mounted', 'host'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, isIssued: isRuntimeObservation, issuedAt: observationIssuedAt },
21
+ { id: 'runtime-measurement', owner: OWNER, postures: ['environment', 'mounted', 'host'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.RUNTIME_UNMEASURED, isIssued: isRuntimeObservation, issuedAt: observationIssuedAt },
22
+ { id: 'mount-posture', owner: OWNER, postures: ['mounted', 'host'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.MOUNT_POSTURE_UNAVAILABLE, isIssued: isRuntimeObservation, issuedAt: observationIssuedAt },
23
+ ]);
24
+ export const runtimeHealthObservationDeclarations = Object.freeze([
25
+ { id: 'runtime-mount-materialization', owner: OWNER, postures: ['running-stack'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.MOUNT_MATERIALIZATION_UNAVAILABLE, isIssued: isRuntimeObservation, issuedAt: observationIssuedAt },
26
+ { id: 'runtime-port-family', owner: OWNER, postures: ['running-stack'], stages: [{ stage: 'bootstrap', disposition: 'not-applicable', reason: 'ports are leased after the first seed' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, isIssued: isRuntimeObservation, issuedAt: observationIssuedAt },
27
+ ]);
28
+ const issuedObservations = new WeakMap();
29
+ const issuedRuntimePlans = new WeakMap();
30
+ function issueObservation(value) {
31
+ const snapshot = snapshotEnvironmentObservation(value);
32
+ if (!snapshot || snapshot.owner !== OWNER)
33
+ throw new RefusalError('runtime observation is malformed');
34
+ issuedObservations.set(snapshot, Date.now());
35
+ return snapshot;
36
+ }
37
+ export function isRuntimeObservation(value) {
38
+ return value !== null && typeof value === 'object' && issuedObservations.has(value);
39
+ }
40
+ function observationIssuedAt(value) {
41
+ return value !== null && typeof value === 'object' ? issuedObservations.get(value) : undefined;
42
+ }
43
+ export function observeRuntimeExecution(input) {
44
+ let plan;
45
+ let suppliedOutcome;
46
+ let owned;
47
+ try {
48
+ plan = input.plan;
49
+ suppliedOutcome = input.outcome;
50
+ owned = snapshotJson(issuedRuntimePlans.get(plan));
51
+ }
52
+ catch {
53
+ throw new RefusalError('runtime execution evidence is not authentic');
54
+ }
55
+ const values = owned && typeof owned === 'object' && !Array.isArray(owned) ? owned : undefined;
56
+ const contract = issuedRuntimePlans.get(plan);
57
+ const step = plan?.steps?.[0];
58
+ const outcome = step ? authenticatedExecutionOutcome(suppliedOutcome, step) : undefined;
59
+ if (!contract || !step || !outcome || typeof values?.stackId !== 'string' || !/^stack:[a-f0-9]{64}$/.test(values.stackId)
60
+ || !Number.isSafeInteger(values.coldStartLimitMillis) || values.coldStartLimitMillis < 1) {
61
+ throw new RefusalError('runtime execution evidence is not authentic');
62
+ }
63
+ if (!Array.isArray(values.expectedMountSources) || values.expectedMountSources.length > 4_096
64
+ || new Set(values.expectedMountSources).size !== values.expectedMountSources.length
65
+ || values.expectedMountSources.some((path) => typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0'))) {
66
+ throw new RefusalError('runtime mount expectation is malformed');
67
+ }
68
+ const stackId = values.stackId;
69
+ const coldStartLimitMillis = values.coldStartLimitMillis;
70
+ const expectedMountSources = values.expectedMountSources;
71
+ const observedAt = outcome.receipt?.finishedAt;
72
+ if (!observedAt || !isCanonicalTimestamp(observedAt))
73
+ throw new RefusalError('runtime execution evidence is not authentic');
74
+ const runtimeAvailable = outcome.kind === 'complete';
75
+ const observations = [issueObservation({
76
+ code: ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, owner: OWNER, stackId, source: step.argv0,
77
+ observed: { state: 'available', value: runtimeAvailable, observedAt }, expectation: { kind: 'equal', value: true },
78
+ applicability: { state: 'required', posture: 'mounted' }, freshness: { state: 'current' }, remedy: 'restore the declared runtime executable and retry the bounded probe',
79
+ })];
80
+ let mountObserved;
81
+ if (contract.operation !== 'validate' || outcome.kind !== 'complete') {
82
+ mountObserved = { state: 'unavailable', reason: 'mount evidence requires a successful runtime validation plan', observedAt };
83
+ }
84
+ else {
85
+ let sources;
86
+ let mountCause = '';
87
+ try {
88
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(outcome.stdout);
89
+ const parsed = parseDocument(source).toJS();
90
+ const values = [];
91
+ if (!parsed || typeof parsed !== 'object' || !parsed.services || typeof parsed.services !== 'object'
92
+ || Object.keys(parsed.services).length > 4_096)
93
+ throw new RefusalError('invalid services');
94
+ for (const service of Object.values(parsed.services)) {
95
+ if (!service || typeof service !== 'object' || !Array.isArray(service.volumes) || service.volumes.length > 4_096)
96
+ continue;
97
+ for (const volume of service.volumes) {
98
+ if (typeof volume === 'string') {
99
+ const source = volume.split(':', 1)[0];
100
+ if (isAbsolute(source) && normalize(source) === source && !source.includes('\0'))
101
+ values.push(source);
102
+ }
103
+ else if (volume && typeof volume === 'object' && volume.type === 'bind'
104
+ && typeof volume.source === 'string')
105
+ values.push(volume.source);
106
+ }
107
+ }
108
+ sources = [...new Set(values)].sort();
109
+ }
110
+ catch (error) {
111
+ mountCause = `: ${errorCause(error)}`;
112
+ }
113
+ mountObserved = sources
114
+ ? { state: 'available', value: mountSourcesMatch(sources, expectedMountSources), observedAt }
115
+ : { state: 'failed', reason: `runtime mount output is malformed or unreadable${mountCause}`, observedAt };
116
+ }
117
+ observations.push(issueObservation({
118
+ code: ENVIRONMENT_CODES.MOUNT_POSTURE_UNAVAILABLE, owner: OWNER, stackId, source: step.id,
119
+ observed: mountObserved, expectation: { kind: 'equal', value: true }, applicability: { state: 'required', posture: 'mounted' },
120
+ freshness: { state: 'current' }, remedy: 'restore the declared checkout mount sources',
121
+ }));
122
+ if (contract.operation === 'up') {
123
+ const startedAt = outcome.receipt?.startedAt;
124
+ const duration = startedAt ? Date.parse(observedAt) - Date.parse(startedAt) : Number.NaN;
125
+ observations.push(issueObservation({
126
+ code: ENVIRONMENT_CODES.MACHINE_COLD_START_EXCEEDED, owner: OWNER, stackId, source: step.id,
127
+ observed: Number.isSafeInteger(duration) && duration >= 0
128
+ ? { state: 'available', value: duration, observedAt }
129
+ : { state: 'failed', reason: 'runtime receipt timestamps are incoherent', observedAt },
130
+ expectation: { kind: 'maximum', value: coldStartLimitMillis }, applicability: { state: 'required', posture: 'environment' },
131
+ freshness: { state: 'current' }, remedy: 'restore the declared cold-start budget',
132
+ }));
133
+ }
134
+ return observations;
135
+ }
136
+ const UNRESOLVED_TOKEN = /(?<!\$)\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)|\{\{[^}]*\}\}/;
137
+ // A cockpit's own Compose file interpolates from the environment cockpit hands Compose: the leased
138
+ // [ports.bases] variables and the sibling directory variables, nothing else. A reference with an
139
+ // explicit default is the file's own choice; any other reference would resolve to empty, so it
140
+ // stays refused.
141
+ function composeTokensResolved(source, portNames) {
142
+ const tokens = source.match(new RegExp(UNRESOLVED_TOKEN.source, 'g')) ?? [];
143
+ return tokens.every((token) => {
144
+ const match = token.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])[^}]*)?\}$/);
145
+ return match !== null && (match[2]?.endsWith('-') === true || portNames.includes(match[1]));
146
+ });
147
+ }
148
+ const PROJECT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
149
+ const REMOTE_REFERENCE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
150
+ const MAX_TIMEOUT_MS = 300_000;
151
+ const OUTPUT_LIMIT_BYTES = 65_536;
152
+ const RUNTIME_OPERATIONS = ['validate', 'up', 'down'];
153
+ const SHA256 = /^[a-f0-9]{64}$/;
154
+ const VERSION = /^\d+\.\d+\.\d+$/;
155
+ // What Docker and Compose print: a numeric core and an optional vendor suffix, such as Docker
156
+ // Desktop's `2.29.7-desktop.1`. Accepting only the bare core refused every Docker Desktop runtime.
157
+ // Floors and comparisons use the core; the full string stays as evidence.
158
+ const OBSERVED_VERSION = /^(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.-]+)?$/;
159
+ /** The numeric core of a version Docker or Compose printed, or undefined when it has none. */
160
+ export function observedVersionCore(value) {
161
+ return value.match(OBSERVED_VERSION)?.[1];
162
+ }
163
+ function captureOwnValues(value, keys) {
164
+ try {
165
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || !exactOwnKeys(value, keys))
166
+ return undefined;
167
+ const descriptors = Object.getOwnPropertyDescriptors(value);
168
+ const captured = {};
169
+ for (const key of keys) {
170
+ const descriptor = descriptors[key];
171
+ if (!descriptor || !('value' in descriptor))
172
+ return undefined;
173
+ captured[key] = descriptor.value;
174
+ }
175
+ return captured;
176
+ }
177
+ catch {
178
+ // A throwing accessor or proxy trap makes the input unreadable, which the caller refuses.
179
+ return undefined;
180
+ }
181
+ }
182
+ function runtimeAdapterInput(value) {
183
+ const baseKeys = ['runtime', 'composeVersion', 'executable', 'operation', 'cwd', 'composeFiles', 'projectName', 'env', 'ports', 'labels', 'timeoutMs'];
184
+ const ownedKeys = [...baseKeys, 'stackId', 'coldStartLimitMillis', 'expectedMountSources'];
185
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
186
+ || !([baseKeys, [...baseKeys, 'activeProfiles'], ownedKeys, [...ownedKeys, 'activeProfiles']]
187
+ .some((keys) => exactOwnKeys(value, keys))))
188
+ return undefined;
189
+ const input = value;
190
+ if (input.runtime === null || typeof input.runtime !== 'object' || !exactOwnKeys(input.runtime, ['name', 'version', 'executableSha256'])
191
+ || Object.values(input.runtime).some((part) => typeof part !== 'string')
192
+ || !isResolvedDockerExecutable(input.executable)
193
+ || !Array.isArray(input.composeFiles) || !Array.isArray(input.ports)
194
+ || input.env === null || typeof input.env !== 'object' || Array.isArray(input.env)
195
+ || input.labels === null || typeof input.labels !== 'object' || Array.isArray(input.labels)
196
+ || Object.entries(input.env).some(([key, part]) => key.length === 0 || typeof part !== 'string')
197
+ || Object.entries(input.labels).some(([key, part]) => key.length === 0 || typeof part !== 'string')
198
+ || input.ports.some((port) => typeof port !== 'number')
199
+ || typeof input.composeVersion !== 'string' || typeof input.operation !== 'string' || typeof input.cwd !== 'string'
200
+ || typeof input.projectName !== 'string' || typeof input.timeoutMs !== 'number'
201
+ || input.stackId !== undefined && (typeof input.stackId !== 'string' || !/^stack:[a-f0-9]{64}$/.test(input.stackId))
202
+ || input.coldStartLimitMillis !== undefined && (!Number.isSafeInteger(input.coldStartLimitMillis) || input.coldStartLimitMillis < 1)
203
+ || input.expectedMountSources !== undefined && (!Array.isArray(input.expectedMountSources)
204
+ || input.expectedMountSources.length > 4_096
205
+ || new Set(input.expectedMountSources).size !== input.expectedMountSources.length
206
+ || input.expectedMountSources.some((path) => typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0')))
207
+ || input.activeProfiles !== undefined && (!Array.isArray(input.activeProfiles)
208
+ || input.activeProfiles.length > 64 || new Set(input.activeProfiles).size !== input.activeProfiles.length
209
+ || input.activeProfiles.some((profile) => typeof profile !== 'string' || !SERVICE_ID.test(profile)))
210
+ || input.composeFiles.some((artifact) => artifact === null || typeof artifact !== 'object'
211
+ || !exactOwnKeys(artifact, ['reference', 'canonicalPath', 'source', 'sha256'])
212
+ || Object.values(artifact).some((part) => typeof part !== 'string')))
213
+ return undefined;
214
+ return {
215
+ ...input,
216
+ ...(input.expectedMountSources === undefined
217
+ ? {}
218
+ : { expectedMountSources: [...input.expectedMountSources].sort() }),
219
+ ...(input.activeProfiles === undefined ? {} : { activeProfiles: [...input.activeProfiles].sort() }),
220
+ };
221
+ }
222
+ export function classifyResolvedRuntimeLiterals(values) {
223
+ let captured;
224
+ try {
225
+ if (!Array.isArray(values))
226
+ return 'unsafe';
227
+ const length = values.length;
228
+ const keys = Object.keys(values);
229
+ if (!Number.isSafeInteger(length) || length < 0 || length > 4_096
230
+ || keys.length !== length || keys.some((key, index) => key !== String(index)))
231
+ return 'unsafe';
232
+ captured = keys.map((key) => values[key]);
233
+ }
234
+ catch {
235
+ // A throwing accessor or proxy trap makes the list unsafe, which the caller refuses.
236
+ return 'unsafe';
237
+ }
238
+ const owned = snapshotJson(captured);
239
+ if (!Array.isArray(owned) || owned.some((value) => typeof value !== 'string'))
240
+ return 'unsafe';
241
+ if (owned.some((value) => UNRESOLVED_TOKEN.test(value)))
242
+ return 'unresolved';
243
+ if (owned.some((value) => value.includes('\0')))
244
+ return 'unsafe';
245
+ return 'resolved';
246
+ }
247
+ function refusal(code, message, target) {
248
+ return {
249
+ kind: 'refusal',
250
+ status: 2,
251
+ diagnostics: [{ severity: 'error', code, message, target }],
252
+ result: null,
253
+ };
254
+ }
255
+ const issuedMachineFloorEvidence = new WeakSet();
256
+ const issuedMachineFloorAuthorities = new WeakMap();
257
+ export function issueMachineFloorAuthority(input) {
258
+ if (input === null || typeof input !== 'object' || Array.isArray(input)
259
+ || !exactOwnKeys(input, ['configuration', 'checkout']))
260
+ return undefined;
261
+ const configurationDescriptor = Object.getOwnPropertyDescriptor(input, 'configuration');
262
+ const checkoutDescriptor = Object.getOwnPropertyDescriptor(input, 'checkout');
263
+ if (!configurationDescriptor || !('value' in configurationDescriptor)
264
+ || !checkoutDescriptor || !('value' in checkoutDescriptor))
265
+ return undefined;
266
+ const configuration = consumeMachineFloorConfigProjection(configurationDescriptor.value);
267
+ const checkout = consumeSetupCheckoutProjection(checkoutDescriptor.value);
268
+ if (!configuration || !checkout || configuration.cockpitName !== checkout.cockpitName
269
+ || configuration.source.path !== checkout.source.path
270
+ || configuration.source.sha256 !== checkout.source.sha256
271
+ || configuration.source.byteLength !== checkout.source.byteLength)
272
+ return undefined;
273
+ const floor = {
274
+ memoryBytes: configuration.floor.runtimeMemoryMb * 1_024 * 1_024,
275
+ cpuMillis: configuration.floor.runtimeCpus * 1_000,
276
+ freeDiskBytes: configuration.floor.freeDiskMb * 1_024 * 1_024,
277
+ };
278
+ if (Object.values(floor).some((value) => !Number.isSafeInteger(value) || value < 1))
279
+ return undefined;
280
+ const authority = { kind: 'machine-floor-authority' };
281
+ issuedMachineFloorAuthorities.set(authority, Object.freeze({
282
+ stackId: deriveStackId({ cockpitId: checkout.cockpitId, checkoutId: checkout.checkoutId, variant: { kind: 'shared' } }),
283
+ checkoutPath: dirname(configuration.source.path),
284
+ configSha256: configuration.runtimeSha256,
285
+ floor: Object.freeze(floor),
286
+ }));
287
+ return { authority, checkout };
288
+ }
289
+ export function isMachineFloorEvidence(value) {
290
+ return value !== null && typeof value === 'object' && issuedMachineFloorEvidence.has(value);
291
+ }
292
+ const MACHINE_FLOOR_ENV = new Set(['PATH', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH']);
293
+ // Keep the filesystem query inside the bounded executor; a filesystem stall must not hold the CLI.
294
+ const DISK_CAPACITY_COLLECTOR = `
295
+ import { statfsSync } from 'node:fs'
296
+ if (process.argv.length !== 2) throw new RefusalError('Disk capacity requires one filesystem path')
297
+ const status = statfsSync(process.argv[1], { bigint: true })
298
+ const bytes = status.bsize * status.bavail
299
+ if (status.bsize <= 0n || status.bavail < 0n || bytes > BigInt(Number.MAX_SAFE_INTEGER)) {
300
+ throw new RefusalError('Available filesystem capacity is outside the supported byte range')
301
+ }
302
+ process.stdout.write(bytes.toString() + '\\n')
303
+ `;
304
+ function safeMachineFloorEnvironment(value) {
305
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
306
+ && Object.entries(value).every(([name, item]) => MACHINE_FLOOR_ENV.has(name) && typeof item === 'string' && !item.includes('\0'));
307
+ }
308
+ function machineFloorEnvironment(value) {
309
+ return Object.fromEntries(Object.entries(value).filter(([name]) => MACHINE_FLOOR_ENV.has(name)));
310
+ }
311
+ /**
312
+ * Name the container runtime product from one `docker info` reading, or null when it is not one we
313
+ * recognise. The identity selects this machine's runtime measurement, so a wrong match would
314
+ * admit a runtime on another product's measurement; anything unrecognised therefore stays null.
315
+ *
316
+ * Provenance of each rule: `orbstack` was observed on a host running OrbStack (OperatingSystem
317
+ * "OrbStack", Name "orbstack", OSType "linux"). The `docker-desktop`, `colima`, `rancher-desktop` and
318
+ * `docker-engine` rules are derived from those vendors' documented `docker info` output and have not
319
+ * been probed on a host here.
320
+ */
321
+ export function runtimeIdentity(input) {
322
+ if (input.operatingSystem === 'Docker Desktop')
323
+ return 'docker-desktop';
324
+ if (input.operatingSystem === 'OrbStack')
325
+ return 'orbstack';
326
+ // Colima names its VM after the profile: "colima" or "colima-<profile>".
327
+ if (input.name.startsWith('colima'))
328
+ return 'colima';
329
+ // Rancher Desktop runs its engine in a Lima VM named "lima-rancher-desktop".
330
+ if (input.name.endsWith('rancher-desktop'))
331
+ return 'rancher-desktop';
332
+ // On a Linux host the daemon is the host's own Docker Engine; on macOS an unrecognised
333
+ // OperatingSystem is some other VM product and must not borrow this identity.
334
+ if (input.hostPlatform === 'linux' && input.osType === 'linux')
335
+ return 'docker-engine';
336
+ return null;
337
+ }
338
+ const DOCKER_IDENTITY_FORMAT = '[{{json .OperatingSystem}},{{json .Name}},{{json .OSType}}]';
339
+ function dockerIdentityFields(text) {
340
+ // Unparsable identity output yields no fields; the caller reports the probe as unauthenticated output.
341
+ const parsed = tryParseJson(text);
342
+ if (!Array.isArray(parsed) || parsed.length !== 3)
343
+ return undefined;
344
+ const [operatingSystem, name, osType] = parsed;
345
+ const bounded = (value, limit) => typeof value === 'string'
346
+ && value.length > 0 && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
347
+ return bounded(operatingSystem, 128) && bounded(name, 128) && bounded(osType, 16)
348
+ ? [operatingSystem, name, osType] : undefined;
349
+ }
350
+ function runtimeVersionUnavailable(reason) {
351
+ return { kind: 'unavailable', code: ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, reason };
352
+ }
353
+ // The runtime answered, so starting it is the wrong remedy: the installed tool prints a version
354
+ // that floors and measurements cannot compare.
355
+ function runtimeVersionUnparseable(tool, command, printed) {
356
+ return {
357
+ kind: 'unavailable',
358
+ code: ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE,
359
+ reason: `${tool} reported version ${JSON.stringify(printed.slice(0, 64))} from \`${command}\`, which has no numeric X.Y.Z core`,
360
+ fix: 'install a Docker Engine and Docker Compose release whose `docker version --format {{.Server.Version}}` and `docker compose version --short` print X.Y.Z (a suffix such as -desktop.1 is fine), then retry',
361
+ };
362
+ }
363
+ const issuedDockerRuntimeObservations = new WeakSet();
364
+ const issuedRuntimeMeasurementAdmissions = new WeakMap();
365
+ const issuedRuntimeMeasurementProbeAuthorities = new WeakMap();
366
+ export function issueRuntimeMeasurementProbeAuthority(observation) {
367
+ if (!observation || typeof observation !== 'object' || observation.kind !== 'complete'
368
+ || !issuedDockerRuntimeObservations.has(observation))
369
+ throw new RefusalError('runtime measurement probe evidence is not authentic');
370
+ const authority = { kind: 'runtime-measurement-probe-authority' };
371
+ issuedRuntimeMeasurementProbeAuthorities.set(authority, observation);
372
+ return authority;
373
+ }
374
+ export function runtimeMeasurementProbeObservation(authority, executableSha256) {
375
+ const issued = authority && typeof authority === 'object' ? issuedRuntimeMeasurementProbeAuthorities.get(authority) : undefined;
376
+ if (authority && typeof authority === 'object')
377
+ issuedRuntimeMeasurementProbeAuthorities.delete(authority);
378
+ const age = issued ? Date.now() - Date.parse(issued.observedAt) : Number.NaN;
379
+ return issued && issued.runtime.executableSha256 === executableSha256
380
+ && age >= 0 && age <= 300_000 ? issued : undefined;
381
+ }
382
+ function runtimeUnmeasured(reason) {
383
+ return { kind: 'unavailable', code: ENVIRONMENT_CODES.RUNTIME_UNMEASURED, reason };
384
+ }
385
+ /** A floor cockpit writes: a bare numeric core. */
386
+ export function isVersionFloor(value) {
387
+ return VERSION.test(value);
388
+ }
389
+ /** Whether a version Docker or Compose printed is at least `floor`; false when either is malformed. */
390
+ export function versionAtLeast(actual, floor) {
391
+ const core = observedVersionCore(actual);
392
+ if (core === undefined || !isVersionFloor(floor))
393
+ return false;
394
+ const left = core.split('.').map(Number);
395
+ const right = floor.split('.').map(Number);
396
+ for (let index = 0; index < left.length; index += 1) {
397
+ if (left[index] !== right[index])
398
+ return left[index] > right[index];
399
+ }
400
+ return true;
401
+ }
402
+ export function admitRuntimeMeasurement(observation, measurements) {
403
+ if (!observation || typeof observation !== 'object' || !issuedDockerRuntimeObservations.has(observation)
404
+ || observation.kind !== 'complete')
405
+ return runtimeUnmeasured('Docker runtime evidence is unavailable or unauthenticated');
406
+ const refuse = (reason) => {
407
+ const result = runtimeUnmeasured(reason);
408
+ issuedRuntimeMeasurementAdmissions.set(result, observation);
409
+ return result;
410
+ };
411
+ const identity = observation.runtimeIdentity;
412
+ const dockerInfo = `docker info: OperatingSystem ${JSON.stringify(observation.operatingSystem)}, Name ${JSON.stringify(observation.name)}, `
413
+ + `OSType ${JSON.stringify(observation.osType)}; engine ${observation.runtime.version}; host ${observation.hostPlatform}`;
414
+ const rows = measurements.kind === 'read' ? measurements.rows : [];
415
+ const listed = rows.map((row) => `${row.runtime} on ${JSON.stringify(row.daemon.slice(0, 40))} (engine ${row.engineVersion.slice(0, 32)}, Compose floor ${row.composeFloor})`);
416
+ const listedRows = listed.length === 0 ? 'none'
417
+ : `${listed.slice(0, 4).join(', ')}${listed.length > 4 ? ` and ${listed.length - 4} more` : ''}`;
418
+ // Every refusal names what was detected and what this machine's record holds, so the operator can
419
+ // act on the message alone; the remedy is always a fresh measurement of this exact runtime. The
420
+ // reason also travels in an observation, whose text is byte-bounded, so the row list yields first.
421
+ const detail = (reason, action) => {
422
+ const full = `${reason} (${dockerInfo}; measured in ${measurements.path}: ${listedRows}). ${action}`;
423
+ return Buffer.byteLength(full) <= OBSERVATION_REASON_BYTE_LIMIT ? full
424
+ : `${reason} (${dockerInfo}; measured in ${measurements.path}: ${rows.length}). ${action}`;
425
+ };
426
+ const remeasure = (reason) => refuse(detail(reason, `Measure it on this machine with: ${cockpitCommand('runtime measure', { args: '--image <local-image>' })}`));
427
+ if (!identity) {
428
+ return refuse(detail('Docker runtime is not a recognised product, so no measurement can apply', 'Report these docker info values to the cockpit maintainers so the runtime can be identified'));
429
+ }
430
+ if (measurements.kind === 'unreadable') {
431
+ return remeasure(`This machine's runtime measurement record is unreadable: ${measurements.reason}; a new measurement replaces it`);
432
+ }
433
+ const row = rows.find((candidate) => candidate.runtime === identity && candidate.daemon === observation.name);
434
+ if (!row)
435
+ return remeasure(`Runtime ${identity} on daemon ${JSON.stringify(observation.name)} has not been measured on this machine`);
436
+ // A measurement certifies the engine it ran against; an upgraded or downgraded engine is a
437
+ // different runtime until it passes the probe too.
438
+ if (row.engineVersion !== observation.runtime.version) {
439
+ return remeasure(`Runtime ${identity} was measured with engine ${row.engineVersion} and now runs engine ${observation.runtime.version}`);
440
+ }
441
+ if (!VERSION.test(row.composeFloor) || !versionAtLeast(observation.composeVersion, row.composeFloor)) {
442
+ return remeasure(`Runtime ${identity} runs Docker Compose ${observation.composeVersion}, below the measured floor ${row.composeFloor}`);
443
+ }
444
+ const result = { kind: 'admitted', runtimeIdentity: identity,
445
+ measurement: `${identity} on ${JSON.stringify(observation.name)}, engine ${row.engineVersion}, Compose floor ${row.composeFloor}, measured ${row.measuredOn}` };
446
+ issuedRuntimeMeasurementAdmissions.set(result, observation);
447
+ return result;
448
+ }
449
+ export function observeRuntimeMeasurement(admission, stackId) {
450
+ const runtime = admission && typeof admission === 'object' ? issuedRuntimeMeasurementAdmissions.get(admission) : undefined;
451
+ if (!runtime || typeof stackId !== 'string' || !/^stack:[a-f0-9]{64}$/.test(stackId)) {
452
+ throw new RefusalError('runtime measurement admission is not authentic');
453
+ }
454
+ return issueObservation({
455
+ code: ENVIRONMENT_CODES.RUNTIME_UNMEASURED,
456
+ owner: OWNER,
457
+ stackId,
458
+ // An admitted runtime names the measurement that admitted it, so doctor says which runtime is measured.
459
+ source: admission.kind === 'admitted' ? admission.measurement : runtime.operatingSystem,
460
+ observed: admission.kind === 'admitted'
461
+ ? { state: 'available', value: true, observedAt: runtime.observedAt }
462
+ : { state: 'unavailable', reason: admission.reason, observedAt: runtime.observedAt },
463
+ expectation: { kind: 'equal', value: true },
464
+ applicability: { state: 'required', posture: 'mounted' },
465
+ freshness: { state: 'current' },
466
+ remedy: 'measure this runtime on this machine: a passing candidate and a rejected broken control for its exact engine and Compose floor',
467
+ });
468
+ }
469
+ export async function observeDockerRuntime(rawInput, execute) {
470
+ let input;
471
+ try {
472
+ const captured = snapshotJson(rawInput);
473
+ if (!captured || typeof captured !== 'object' || Array.isArray(captured)
474
+ || !exactOwnKeys(captured, ['executable', 'cwd', 'env', 'timeoutMs'])) {
475
+ return runtimeVersionUnavailable('Docker runtime version input is malformed');
476
+ }
477
+ const candidate = captured;
478
+ const executable = resolvedDockerExecutable(candidate.executable);
479
+ if (!executable || typeof candidate.cwd !== 'string' || !isAbsolute(candidate.cwd)
480
+ || normalize(candidate.cwd) !== candidate.cwd || candidate.cwd.includes('\0')
481
+ || !safeMachineFloorEnvironment(candidate.env)
482
+ || !Number.isSafeInteger(candidate.timeoutMs) || candidate.timeoutMs < 1 || candidate.timeoutMs > 30_000
483
+ || typeof execute !== 'function') {
484
+ return runtimeVersionUnavailable('Docker runtime version input needs a resolved executable, local working directory, and bounded environment');
485
+ }
486
+ input = { ...candidate, executable, env: { ...candidate.env } };
487
+ }
488
+ catch (error) {
489
+ return runtimeVersionUnavailable(`Docker runtime version input is unreadable: ${errorCause(error)}`);
490
+ }
491
+ const batchId = randomBytes(16).toString('hex');
492
+ const step = (id, args, stdoutLimitBytes = 128) => ({
493
+ id,
494
+ argv: [input.executable.canonicalPath, ...args],
495
+ argv0: input.executable.invocationPath,
496
+ cwd: input.cwd,
497
+ env: input.env,
498
+ timeoutMs: input.timeoutMs,
499
+ stdoutLimitBytes,
500
+ stderrLimitBytes: 512,
501
+ paths: [],
502
+ ports: [],
503
+ labels: { purpose: id, batchId },
504
+ expected: [],
505
+ consumedFiles: [{
506
+ path: input.executable.canonicalPath, sha256: input.executable.sha256,
507
+ maxBytes: 536_870_912, argvIndexes: [0], executable: true,
508
+ executionMode: input.executable.executionMode,
509
+ }],
510
+ filesystemChecks: [],
511
+ compensation: [],
512
+ });
513
+ const versionStep = step('runtime:docker-server-version', ['version', '--format', '{{.Server.Version}}']);
514
+ const composeStep = step('runtime:docker-compose-version', ['compose', 'version', '--short']);
515
+ // One reading supplies every identity field, so they describe the same daemon. JSON-encoding each
516
+ // field keeps a hostname or product label containing separators from shifting the others.
517
+ const identityStep = step('runtime:docker-operating-system', ['info', '--format', DOCKER_IDENTITY_FORMAT], 512);
518
+ // Set only by the probe that threw; every later step returns as soon as a probe fails.
519
+ let probeCause = '';
520
+ const readText = async (probe, limit = 128) => {
521
+ let raw;
522
+ try {
523
+ raw = await execute(probe);
524
+ }
525
+ catch (error) {
526
+ probeCause = `: ${errorCause(error)}`;
527
+ return undefined;
528
+ }
529
+ const outcome = authenticatedExecutionOutcome(raw, probe);
530
+ if (!outcome || outcome.kind !== 'complete' || outcome.stderr.byteLength !== 0)
531
+ return undefined;
532
+ try {
533
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(outcome.stdout).trim();
534
+ return value.length > 0 && value.length <= limit && !value.includes('\0')
535
+ ? { value, observedAt: outcome.receipt.finishedAt } : undefined;
536
+ }
537
+ catch {
538
+ // Undecodable output yields no reading; the caller reports the probe as unauthenticated output.
539
+ return undefined;
540
+ }
541
+ };
542
+ const server = await readText(versionStep);
543
+ if (!server)
544
+ return runtimeVersionUnavailable('Docker server version probe failed or returned unauthenticated output' + probeCause);
545
+ if (!OBSERVED_VERSION.test(server.value))
546
+ return runtimeVersionUnparseable('Docker Engine', 'docker version --format {{.Server.Version}}', server.value);
547
+ const compose = await readText(composeStep);
548
+ if (!compose)
549
+ return runtimeVersionUnavailable('Docker Compose version probe failed or returned unauthenticated output' + probeCause);
550
+ if (!OBSERVED_VERSION.test(compose.value))
551
+ return runtimeVersionUnparseable('Docker Compose', 'docker compose version --short', compose.value);
552
+ const identityText = await readText(identityStep, 512);
553
+ const fields = identityText === undefined ? undefined : dockerIdentityFields(identityText.value);
554
+ if (!identityText || !fields)
555
+ return runtimeVersionUnavailable('Docker identity probe (docker info OperatingSystem, Name, OSType) failed or returned unauthenticated output' + probeCause);
556
+ const [operatingSystem, name, osType] = fields;
557
+ const hostPlatform = platform();
558
+ const result = {
559
+ kind: 'complete',
560
+ runtime: { name: 'docker', version: server.value, executableSha256: input.executable.sha256 },
561
+ runtimeIdentity: runtimeIdentity({ operatingSystem, name, osType, hostPlatform }),
562
+ operatingSystem,
563
+ name,
564
+ osType,
565
+ hostPlatform,
566
+ composeVersion: compose.value,
567
+ observedAt: [server.observedAt, compose.observedAt, identityText.observedAt].sort().at(-1),
568
+ };
569
+ issuedDockerRuntimeObservations.add(result);
570
+ return result;
571
+ }
572
+ function machineFloorPlanInput(value) {
573
+ try {
574
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)
575
+ && exactOwnKeys(value, ['runtimePlan', 'diskExecutable', 'timeoutMs', 'authority'])) {
576
+ const runtimeDescriptor = Object.getOwnPropertyDescriptor(value, 'runtimePlan');
577
+ if (!runtimeDescriptor || !('value' in runtimeDescriptor))
578
+ return undefined;
579
+ const runtime = issuedRuntimePlans.get(runtimeDescriptor.value);
580
+ const snapshot = snapshotJson({
581
+ diskExecutable: value.diskExecutable,
582
+ timeoutMs: value.timeoutMs,
583
+ });
584
+ const authorityDescriptor = Object.getOwnPropertyDescriptor(value, 'authority');
585
+ const authority = authorityDescriptor && 'value' in authorityDescriptor
586
+ ? issuedMachineFloorAuthorities.get(authorityDescriptor.value) : undefined;
587
+ if (!runtime || !snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
588
+ return undefined;
589
+ const candidate = snapshot;
590
+ const diskExecutable = resolvedExecutable(candidate.diskExecutable);
591
+ const env = machineFloorEnvironment(runtime.env);
592
+ if (!diskExecutable || !safeMachineFloorEnvironment(env)
593
+ || !authority || authority.stackId !== runtime.stackId || authority.configSha256 !== runtime.configSha256
594
+ || !Number.isSafeInteger(candidate.timeoutMs) || candidate.timeoutMs < 1 || candidate.timeoutMs > 30_000)
595
+ return undefined;
596
+ return {
597
+ runtimeExecutable: runtime.runtimeExecutable, diskExecutable, cwd: runtime.cwd,
598
+ env, timeoutMs: candidate.timeoutMs, stackId: runtime.stackId,
599
+ floor: authority.floor,
600
+ };
601
+ }
602
+ const snapshot = snapshotJson(value);
603
+ if (snapshot === undefined || snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)
604
+ || !([['runtimeExecutable', 'diskExecutable', 'cwd', 'env', 'timeoutMs'],
605
+ ['runtimeExecutable', 'diskExecutable', 'cwd', 'env', 'timeoutMs', 'authority']]
606
+ .some((keys) => exactOwnKeys(value, keys))))
607
+ return undefined;
608
+ const candidate = snapshot;
609
+ const runtimeExecutable = resolvedDockerExecutable(candidate.runtimeExecutable);
610
+ const diskExecutable = resolvedExecutable(candidate.diskExecutable);
611
+ if (runtimeExecutable === undefined || diskExecutable === undefined || typeof candidate.cwd !== 'string'
612
+ || !isAbsolute(candidate.cwd) || normalize(candidate.cwd) !== candidate.cwd || candidate.cwd.includes('\0')
613
+ || !safeMachineFloorEnvironment(candidate.env)
614
+ || !Number.isInteger(candidate.timeoutMs) || candidate.timeoutMs <= 0 || candidate.timeoutMs > 30_000)
615
+ return undefined;
616
+ const authorityDescriptor = Object.getOwnPropertyDescriptor(value, 'authority');
617
+ const authority = authorityDescriptor && 'value' in authorityDescriptor
618
+ ? issuedMachineFloorAuthorities.get(authorityDescriptor.value) : undefined;
619
+ if (authorityDescriptor && (!authority || candidate.cwd !== authority.checkoutPath))
620
+ return undefined;
621
+ return {
622
+ runtimeExecutable, diskExecutable, cwd: candidate.cwd,
623
+ env: { ...candidate.env }, timeoutMs: candidate.timeoutMs,
624
+ ...(authority ? { stackId: authority.stackId, floor: authority.floor } : {}),
625
+ };
626
+ }
627
+ catch {
628
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
629
+ return undefined;
630
+ }
631
+ }
632
+ function machineFloorRefusal(code, message, target) {
633
+ return refusal(code, message, target);
634
+ }
635
+ /** Measures the container runtime's memory and CPU and the checkout's free disk. */
636
+ export async function measureMachineFloor(rawInput, execute) {
637
+ const planned = machineFloorPlan(rawInput);
638
+ if (planned.kind !== 'preview')
639
+ return planned;
640
+ const [resources, disk] = planned.plan.steps;
641
+ const evidence = machineFloorEvidence(planned.contract, await execute(resources), await execute(disk));
642
+ return evidence ? { kind: 'complete', evidence } : { kind: 'unavailable' };
643
+ }
644
+ function machineFloorPlan(rawInput) {
645
+ const input = machineFloorPlanInput(rawInput);
646
+ if (input === undefined) {
647
+ return machineFloorRefusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'machine-floor collectors require canonical digest-bound executables and a bounded timeout', 'machine-floor');
648
+ }
649
+ const { runtimeExecutable, diskExecutable } = input;
650
+ const makeStep = (id, argv, executable) => ({
651
+ id, argv: [...argv], argv0: executable.invocationPath, cwd: input.cwd,
652
+ env: { ...input.env }, timeoutMs: input.timeoutMs, stdoutLimitBytes: 4096, stderrLimitBytes: 4096,
653
+ paths: [], ports: [], labels: { collector: id }, expected: [],
654
+ consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912, argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
655
+ filesystemChecks: [],
656
+ compensation: [],
657
+ });
658
+ const steps = [
659
+ makeStep('runtime:measure:resources', [runtimeExecutable.canonicalPath, 'info', '--format', '{{json .MemTotal}}|{{json .NCPU}}'], runtimeExecutable),
660
+ makeStep('runtime:measure:free-disk', [diskExecutable.canonicalPath, '--input-type=module', '-e', DISK_CAPACITY_COLLECTOR, input.cwd], diskExecutable),
661
+ ];
662
+ const plan = snapshotExecutionPlan({ steps });
663
+ if (plan === undefined)
664
+ return machineFloorRefusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'machine-floor collectors did not produce a valid execution plan', input.cwd);
665
+ return { kind: 'preview', plan, contract: {
666
+ ...(input.stackId ? { stackId: input.stackId } : {}),
667
+ ...(input.floor ? { floor: input.floor } : {}),
668
+ } };
669
+ }
670
+ function machineFloorEvidence(contract, resources, disk) {
671
+ if (resources.kind !== 'complete' || disk.kind !== 'complete')
672
+ return undefined;
673
+ let resourceText;
674
+ let diskText;
675
+ try {
676
+ resourceText = new TextDecoder('utf-8', { fatal: true }).decode(resources.stdout).trim();
677
+ diskText = new TextDecoder('utf-8', { fatal: true }).decode(disk.stdout);
678
+ }
679
+ catch {
680
+ // Undecodable output yields no measurement, which the caller reports as unavailable.
681
+ return undefined;
682
+ }
683
+ const resourceMatch = resourceText.match(/^(\d+)\|(\d+(?:\.\d{1,3})?)$/);
684
+ if (!resourceMatch || !/^(?:0|[1-9]\d{0,15})\n$/.test(diskText) || disk.stderr.byteLength !== 0)
685
+ return undefined;
686
+ const memoryBytes = Number(resourceMatch[1]);
687
+ const cpuMillis = Number(resourceMatch[2]) * 1_000;
688
+ const freeDiskBytes = Number(diskText);
689
+ if (![memoryBytes, cpuMillis, freeDiskBytes].every((value) => Number.isSafeInteger(value) && value >= 0))
690
+ return undefined;
691
+ const evidence = {
692
+ memoryBytes, cpuMillis, freeDiskBytes,
693
+ observedAt: resources.receipt.finishedAt > disk.receipt.finishedAt ? resources.receipt.finishedAt : disk.receipt.finishedAt,
694
+ ...(contract.stackId ? { stackId: contract.stackId } : {}),
695
+ ...(contract.floor ? { floor: contract.floor } : {}),
696
+ };
697
+ issuedMachineFloorEvidence.add(evidence);
698
+ return evidence;
699
+ }
700
+ function localComposePath(cwd, source) {
701
+ if (source.reference.length === 0
702
+ || isAbsolute(source.reference)
703
+ || REMOTE_REFERENCE.test(source.reference)
704
+ || source.reference.includes('\\')
705
+ || source.reference.includes('\0'))
706
+ return undefined;
707
+ const target = resolve(cwd, source.reference);
708
+ const fromCwd = relative(cwd, source.canonicalPath);
709
+ if (target !== source.canonicalPath || fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd))
710
+ return undefined;
711
+ return source.canonicalPath;
712
+ }
713
+ function composeArgv(command, files, projectDirectory, projectName, operation, activeProfiles) {
714
+ const fileArgs = files.flatMap((file) => ['--file', file]);
715
+ const profileArgs = activeProfiles.flatMap((profile) => ['--profile', profile]);
716
+ const operationArgs = operation === 'validate'
717
+ ? ['config', '--quiet']
718
+ : operation === 'up'
719
+ ? ['up', '--detach', '--wait']
720
+ : ['down', '--remove-orphans'];
721
+ return [...command, '--project-directory', projectDirectory, ...fileArgs, '--project-name', projectName, ...profileArgs, ...operationArgs];
722
+ }
723
+ function composeStep(input, files, projectDirectory, operation, compensation) {
724
+ const step = {
725
+ id: `runtime:docker-compose:${operation}:${input.projectName}`,
726
+ argv: [...composeArgv([input.executable.canonicalPath, 'compose'], files, projectDirectory, input.projectName, operation, input.activeProfiles ?? [])],
727
+ argv0: input.executable.invocationPath,
728
+ cwd: input.cwd,
729
+ env: { ...input.env },
730
+ timeoutMs: input.timeoutMs,
731
+ stdoutLimitBytes: OUTPUT_LIMIT_BYTES,
732
+ stderrLimitBytes: OUTPUT_LIMIT_BYTES,
733
+ paths: [...files],
734
+ ports: [...input.ports],
735
+ labels: { ...input.labels, operation, runtime: input.runtime.name, composeVersion: input.composeVersion },
736
+ expected: [],
737
+ consumedFiles: [
738
+ { path: input.executable.canonicalPath, sha256: input.executable.sha256, maxBytes: 536_870_912, argvIndexes: [0], executable: true, executionMode: input.executable.executionMode },
739
+ ...input.composeFiles.map(({ canonicalPath: path, sha256 }, index) => ({ path, sha256, maxBytes: 1_048_576, argvIndexes: [5 + index * 2], executable: false })),
740
+ ],
741
+ filesystemChecks: [],
742
+ compensation: [...compensation],
743
+ };
744
+ return step;
745
+ }
746
+ function exactInputValues(input) {
747
+ return [
748
+ input.executable.canonicalPath,
749
+ input.executable.invocationPath,
750
+ input.cwd,
751
+ input.projectName,
752
+ ...Object.keys(input.env),
753
+ ...Object.values(input.env),
754
+ ...Object.keys(input.labels),
755
+ ...Object.values(input.labels),
756
+ ...(input.activeProfiles ?? []),
757
+ ];
758
+ }
759
+ function hasUnresolvedInput(input) {
760
+ return classifyResolvedRuntimeLiterals(exactInputValues(input)) === 'unresolved';
761
+ }
762
+ function localBuildContext(value) {
763
+ return typeof value === 'string' && value.length > 0 && !value.includes('\0')
764
+ && !/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[^/\s]+@[^:]+:/i.test(value);
765
+ }
766
+ function composeBuildContextsSafe(source) {
767
+ try {
768
+ if (Buffer.byteLength(source) > 1_048_576)
769
+ return false;
770
+ const document = parseDocument(source, { merge: true });
771
+ if (document.errors.length > 0 || document.warnings.length > 0)
772
+ return false;
773
+ const root = document.toJS({ maxAliasCount: 100 });
774
+ if (root === null || typeof root !== 'object' || Array.isArray(root))
775
+ return false;
776
+ const rootRecord = root;
777
+ if (rootRecord.include !== undefined)
778
+ return false;
779
+ const services = rootRecord.services;
780
+ if (services === undefined)
781
+ return true;
782
+ if (services === null || typeof services !== 'object' || Array.isArray(services))
783
+ return false;
784
+ for (const service of Object.values(services)) {
785
+ if (service === null || typeof service !== 'object' || Array.isArray(service))
786
+ return false;
787
+ const serviceRecord = service;
788
+ if (serviceRecord.extends !== undefined)
789
+ return false;
790
+ const build = serviceRecord.build;
791
+ if (build === undefined)
792
+ continue;
793
+ if (typeof build === 'string') {
794
+ if (!localBuildContext(build))
795
+ return false;
796
+ continue;
797
+ }
798
+ if (build === null || typeof build !== 'object' || Array.isArray(build))
799
+ return false;
800
+ const buildRecord = build;
801
+ if (buildRecord.context !== undefined && !localBuildContext(buildRecord.context))
802
+ return false;
803
+ const additional = buildRecord.additional_contexts;
804
+ if (additional === undefined)
805
+ continue;
806
+ if (Array.isArray(additional)) {
807
+ if (additional.some((entry) => typeof entry !== 'string' || !entry.includes('=') || !localBuildContext(entry.slice(entry.indexOf('=') + 1))))
808
+ return false;
809
+ }
810
+ else if (additional !== null && typeof additional === 'object') {
811
+ if (Object.values(additional).some((entry) => !localBuildContext(entry)))
812
+ return false;
813
+ }
814
+ else
815
+ return false;
816
+ }
817
+ return true;
818
+ }
819
+ catch {
820
+ // Unparsable Compose source is not a local-build declaration; the caller refuses it.
821
+ return false;
822
+ }
823
+ }
824
+ export function createRuntimeAdapter(rawInput, authority) {
825
+ let input = runtimeAdapterInput(snapshotJson(rawInput));
826
+ if (input === undefined)
827
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime input must be bounded readable JSON data', 'runtime-input');
828
+ const capturedAuthority = authority === undefined ? undefined
829
+ : captureOwnValues(authority, ['ownComposeFiles', 'cockpitId', 'stackId'])
830
+ ?? captureOwnValues(authority, ['ownComposeFiles', 'cockpitId', 'stackId', 'portLeaseProjection'])
831
+ ?? captureOwnValues(authority, ['ownComposeFiles', 'cockpitId', 'stackId', 'siblingDirectories'])
832
+ ?? captureOwnValues(authority, ['ownComposeFiles', 'cockpitId', 'stackId', 'portLeaseProjection', 'siblingDirectories']);
833
+ const ownComposeFiles = capturedAuthority?.ownComposeFiles === true;
834
+ const suppliedPortProjection = capturedAuthority?.portLeaseProjection;
835
+ const portProjection = suppliedPortProjection === undefined ? undefined : inspectPortLeaseProjection(suppliedPortProjection);
836
+ if ((authority !== undefined && (!capturedAuthority || !ownComposeFiles))
837
+ || (suppliedPortProjection !== undefined && portProjection === undefined)) {
838
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime artifact authority is malformed', 'runtime-input');
839
+ }
840
+ let interpolatedNames = [];
841
+ if (portProjection !== undefined) {
842
+ const originalEnv = input.env;
843
+ const entries = Object.entries(portProjection.lease.ports).sort(([left], [right]) => left.localeCompare(right));
844
+ const projectedPorts = entries.map(([, port]) => port).sort((left, right) => left - right);
845
+ const requestedPorts = [...input.ports].sort((left, right) => left - right);
846
+ if (input.stackId !== undefined && input.stackId !== portProjection.lease.stackId
847
+ || entries.length === 0 || entries.some(([name]) => !/^[A-Z][A-Z0-9_]*$/.test(name)
848
+ || HOST_PROCESS_CONTROL_ENV.test(name) || Object.hasOwn(originalEnv, name))
849
+ || JSON.stringify(projectedPorts) !== JSON.stringify(requestedPorts)) {
850
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime port authority does not match the stack', input.projectName);
851
+ }
852
+ interpolatedNames = entries.map(([name]) => name);
853
+ input = {
854
+ ...input,
855
+ env: { ...originalEnv, ...Object.fromEntries(entries.map(([name, port]) => [name, String(port)])) },
856
+ };
857
+ }
858
+ // Sibling directories reach Compose the way the lease does: as named variables every derived
859
+ // step keeps, so `${API_DIR}` in a cockpit's own file resolves instead of falling back.
860
+ const suppliedSiblings = snapshotJson(capturedAuthority?.siblingDirectories);
861
+ if (suppliedSiblings !== undefined) {
862
+ const originalEnv = input.env;
863
+ const entries = suppliedSiblings !== null && typeof suppliedSiblings === 'object' && !Array.isArray(suppliedSiblings)
864
+ ? Object.entries(suppliedSiblings).sort(([left], [right]) => left.localeCompare(right)) : undefined;
865
+ if (entries === undefined || entries.some(([name, path]) => !/^[A-Z][A-Z0-9_]*$/.test(name)
866
+ || HOST_PROCESS_CONTROL_ENV.test(name) || Object.hasOwn(originalEnv, name)
867
+ || typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0'))) {
868
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime sibling directory authority is malformed', input.projectName);
869
+ }
870
+ interpolatedNames = [...interpolatedNames, ...entries.map(([name]) => name)];
871
+ input = { ...input, env: { ...originalEnv, ...Object.fromEntries(entries) } };
872
+ }
873
+ if (ownComposeFiles) {
874
+ const runtimeStackId = capturedAuthority?.stackId;
875
+ const runtimeCockpitId = capturedAuthority?.cockpitId;
876
+ if (typeof runtimeStackId !== 'string' || !/^stack:[a-f0-9]{64}$/.test(runtimeStackId)
877
+ || typeof runtimeCockpitId !== 'string' || !/^cockpit:[a-z0-9][a-z0-9-]*:[a-f0-9]{64}$/.test(runtimeCockpitId)) {
878
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime identity labels are unavailable', input.projectName);
879
+ }
880
+ interpolatedNames = [...interpolatedNames, 'COCKPIT_OWNER_ID', 'COCKPIT_STACK_ID'];
881
+ input = { ...input, env: { ...input.env,
882
+ COCKPIT_OWNER_ID: runtimeCockpitId, COCKPIT_STACK_ID: runtimeStackId } };
883
+ }
884
+ if (hasUnresolvedInput(input)) {
885
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime plan contains an unresolved placeholder', 'runtime-input');
886
+ }
887
+ if (classifyResolvedRuntimeLiterals(exactInputValues(input)) === 'unsafe'
888
+ || Object.keys(input.env).some((name) => name.startsWith('COMPOSE_'))) {
889
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime plan contains an unsafe literal or a compose environment override', 'runtime-input');
890
+ }
891
+ input = { ...input, env: composeProcessEnvironment(input.env) };
892
+ if (!isAbsolute(input.cwd)) {
893
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime working directory must be absolute', input.cwd);
894
+ }
895
+ if (input.runtime.name !== 'docker' || !RUNTIME_OPERATIONS.includes(input.operation)) {
896
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime and operation must be explicitly supported', `${input.runtime.name}:${input.operation}`);
897
+ }
898
+ if (!isResolvedDockerExecutable(input.executable)
899
+ || input.executable.sha256 !== input.runtime.executableSha256
900
+ || !OBSERVED_VERSION.test(input.runtime.version)
901
+ || !OBSERVED_VERSION.test(input.composeVersion)
902
+ || !PROJECT_NAME.test(input.projectName)) {
903
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime command or project name is invalid', input.projectName);
904
+ }
905
+ if (!Number.isInteger(input.timeoutMs) || input.timeoutMs <= 0 || input.timeoutMs > MAX_TIMEOUT_MS) {
906
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime timeout must be bounded to at most five minutes', String(input.timeoutMs));
907
+ }
908
+ if (input.ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65_535)
909
+ || new Set(input.ports).size !== input.ports.length) {
910
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime ports must be unique resolved host ports', input.projectName);
911
+ }
912
+ if (input.composeFiles.length === 0) {
913
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime plan needs at least one local compose file', input.cwd);
914
+ }
915
+ const unresolvedCompose = input.composeFiles.find((source) => UNRESOLVED_TOKEN.test(source.reference));
916
+ if (unresolvedCompose !== undefined) {
917
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'compose reference contains an unresolved placeholder', unresolvedCompose.reference);
918
+ }
919
+ const files = [];
920
+ for (const source of input.composeFiles) {
921
+ // Only the cockpit's own Compose files, declared as such by readiness, interpolate.
922
+ if ((UNRESOLVED_TOKEN.test(source.source) && !(ownComposeFiles && composeTokensResolved(source.source, interpolatedNames)))
923
+ || source.source.includes('\0')) {
924
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'compose artifact contains unresolved or unsafe data', source.reference);
925
+ }
926
+ if (createHash('sha256').update(source.source).digest('hex') !== source.sha256) {
927
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'compose artifact digest does not match its source', source.reference);
928
+ }
929
+ if (!composeBuildContextsSafe(source.source)) {
930
+ return refusal(ENVIRONMENT_CODES.RUNTIME_COMPOSE_REMOTE, 'compose build contexts must be local, parseable, and structurally supported', source.reference);
931
+ }
932
+ const local = localComposePath(input.cwd, source);
933
+ if (local === undefined) {
934
+ return refusal(ENVIRONMENT_CODES.RUNTIME_COMPOSE_REMOTE, 'compose references must be confined local files; remote and escaped references are refused', source.reference);
935
+ }
936
+ files.push(local);
937
+ }
938
+ // A cockpit's own files resolve their relative paths as Compose does for the first file it is
939
+ // given; without that authority the plan runs from the given directory.
940
+ const projectDirectory = ownComposeFiles ? composeProjectDirectory(input.cwd, files) : input.cwd;
941
+ const compensation = input.operation === 'up'
942
+ ? [composeStep(input, files, projectDirectory, 'down', [])]
943
+ : [];
944
+ const step = composeStep(input, files, projectDirectory, input.operation, compensation);
945
+ const plan = snapshotExecutionPlan({ steps: [step] });
946
+ if (plan === undefined)
947
+ return refusal(ENVIRONMENT_CODES.RUNTIME_UNMEASURED, 'runtime input did not produce a valid execution plan', input.projectName);
948
+ if (input.stackId && input.coldStartLimitMillis && input.expectedMountSources) {
949
+ issuedRuntimePlans.set(plan, Object.freeze({
950
+ operation: input.operation,
951
+ stackId: input.stackId,
952
+ coldStartLimitMillis: input.coldStartLimitMillis,
953
+ expectedMountSources: Object.freeze([...input.expectedMountSources]),
954
+ runtimeExecutable: Object.freeze({ ...input.executable }),
955
+ cwd: input.cwd,
956
+ env: Object.freeze({ ...input.env }),
957
+ runtimeVersion: input.runtime.version,
958
+ composeVersion: input.composeVersion,
959
+ projectName: input.projectName,
960
+ activeProfiles: Object.freeze([...(input.activeProfiles ?? [])]),
961
+ configSha256: input.labels.configSha256 ?? '',
962
+ interpolatedNames,
963
+ ...(portProjection === undefined ? {} : { portLeaseProjection: portProjection }),
964
+ }));
965
+ }
966
+ return {
967
+ kind: 'preview',
968
+ status: 0,
969
+ diagnostics: [],
970
+ result: { runtime: input.runtime.name, operation: input.operation, projectName: input.projectName },
971
+ plan,
972
+ };
973
+ }
974
+ const issuedTopologyEvidence = new WeakMap();
975
+ const MAX_TOPOLOGY_BYTES = 1_048_576;
976
+ const MAX_TOPOLOGY_SERVICES = 4_096;
977
+ const MAX_TOPOLOGY_EDGES = 16_384;
978
+ // One `up` starts every service inside a single observation window: the declared cold start plus
979
+ // headroom for stop graces and the ordinary probes around each start.
980
+ export const OBSERVATION_WINDOW_HEADROOM_MS = 300_000;
981
+ const MAX_OBSERVATION_WINDOW_MS = MAX_COLD_START_SECONDS * 1_000 + OBSERVATION_WINDOW_HEADROOM_MS;
982
+ // `compose stop` waits up to each service's stop grace (Compose default 10s) before it kills a
983
+ // process that ignores SIGTERM, which a shell or Node.js running as PID 1 does. It stops dependents
984
+ // before their dependencies, one dependency level at a time, so the grace is paid once per level. A
985
+ // stop held to the ordinary 10s command budget timed out on such a service and left the drain
986
+ // attempt for repair; one grace for a two-level stack timed out the same way.
987
+ const COMPOSE_STOP_GRACE_MS = 10_000;
988
+ function dependencyLevels(services) {
989
+ const levels = new Map();
990
+ const level = (id) => {
991
+ const known = levels.get(id);
992
+ if (known !== undefined)
993
+ return known;
994
+ const value = 1 + Math.max(0, ...(services.get(id)?.dependsOn ?? []).map(level));
995
+ levels.set(id, value);
996
+ return value;
997
+ };
998
+ return Math.max(1, ...[...services.keys()].map(level));
999
+ }
1000
+ const SERVICE_ID = /^[a-z0-9][a-z0-9_.-]*$/;
1001
+ const CONTAINER_ID = /^[a-f0-9]{64}$/;
1002
+ const SHORT_CONTAINER_ID = /^[a-f0-9]{12}$/;
1003
+ function unavailableReason(base, error) {
1004
+ return error === undefined ? base : `${base}: ${errorCause(error)}`;
1005
+ }
1006
+ function unavailableTopology(reason = 'runtime topology is unavailable') {
1007
+ return { kind: 'unavailable', reason };
1008
+ }
1009
+ function canonicalWindow(value) {
1010
+ try {
1011
+ const captured = captureOwnValues(value, ['startedAt', 'expiresAt']);
1012
+ const startedAtValue = captured?.startedAt;
1013
+ const expiresAtValue = captured?.expiresAt;
1014
+ if (typeof startedAtValue !== 'string' || typeof expiresAtValue !== 'string'
1015
+ || !isCanonicalTimestamp(startedAtValue) || !isCanonicalTimestamp(expiresAtValue))
1016
+ return undefined;
1017
+ const startedAt = Date.parse(startedAtValue);
1018
+ const expiresAt = Date.parse(expiresAtValue);
1019
+ if (!Number.isFinite(startedAt) || !Number.isFinite(expiresAt)
1020
+ || Math.abs(expiresAt - startedAt) > MAX_OBSERVATION_WINDOW_MS)
1021
+ return undefined;
1022
+ return { startedAt: startedAtValue, expiresAt: expiresAtValue };
1023
+ }
1024
+ catch {
1025
+ // A throwing accessor or proxy trap makes the window malformed, which the caller refuses.
1026
+ return undefined;
1027
+ }
1028
+ }
1029
+ function runtimeCommandEnvironment(env, interpolatedNames) {
1030
+ const allowed = new Set([...DOCKER_CLIENT_ENVIRONMENT, ...interpolatedNames]);
1031
+ return Object.fromEntries(Object.entries(env).filter(([name]) => allowed.has(name)));
1032
+ }
1033
+ function runtimeComposeEnvironment(env, interpolatedNames) {
1034
+ return composeProcessEnvironment(runtimeCommandEnvironment(env, interpolatedNames));
1035
+ }
1036
+ function derivedComposeStep(base, id, operation, operationArgs, stdoutLimitBytes, issuanceId, timeoutLimitMs = MAX_OBSERVATION_WINDOW_MS, spawnNotAfter, interpolatedNames = []) {
1037
+ const configIndex = base.argv.lastIndexOf('config');
1038
+ const suffix = base.argv.slice(configIndex);
1039
+ if (configIndex < 1 || !(suffix.length === 2 && suffix[1] === '--quiet')
1040
+ && !(suffix.length === 4 && suffix[1] === '--format' && suffix[2] === 'json'
1041
+ && suffix[3] === '--no-env-resolution'))
1042
+ return undefined;
1043
+ const env = runtimeComposeEnvironment(base.env, interpolatedNames);
1044
+ const step = {
1045
+ ...base,
1046
+ id,
1047
+ argv: [...base.argv.slice(0, configIndex), ...operationArgs],
1048
+ env,
1049
+ timeoutMs: Math.min(base.timeoutMs, MAX_OBSERVATION_WINDOW_MS, timeoutLimitMs),
1050
+ ...(spawnNotAfter === undefined ? {} : { spawnNotAfter }),
1051
+ stdoutLimitBytes,
1052
+ stderrLimitBytes: OUTPUT_LIMIT_BYTES,
1053
+ ports: [],
1054
+ labels: { ...base.labels, operation, issuanceId },
1055
+ compensation: [],
1056
+ };
1057
+ return snapshotExecutionPlan({ steps: [step] })?.steps[0];
1058
+ }
1059
+ /**
1060
+ * Reads the stack's services through `compose config` from a validated runtime plan, and binds the
1061
+ * topology to that plan, the observation window and the declared bind sources.
1062
+ */
1063
+ export async function observeRuntimeTopology(input) {
1064
+ const { runtimePlan, stackId, configSha256, batchId } = input;
1065
+ const runtime = issuedRuntimePlans.get(runtimePlan);
1066
+ const base = runtimePlan.steps[0];
1067
+ const window = canonicalWindow(input.observationWindow);
1068
+ if (!runtime || runtime.operation !== 'validate' || !base || !window
1069
+ || stackId !== runtime.stackId || !SHA256.test(configSha256) || !SHA256.test(batchId)
1070
+ || base.labels.stackId !== stackId || base.labels.configSha256 !== configSha256
1071
+ || base.labels.batchId !== batchId || base.labels.composeVersion !== runtime.composeVersion
1072
+ || base.cwd !== runtime.cwd || base.argv0 !== runtime.runtimeExecutable.invocationPath)
1073
+ return unavailableTopology();
1074
+ const issuanceId = randomBytes(32).toString('hex');
1075
+ const step = derivedComposeStep(base, `runtime:docker-compose:topology:${runtime.projectName}`, 'config', ['config', '--format', 'json', '--no-env-resolution'], MAX_TOPOLOGY_BYTES, issuanceId, MAX_OBSERVATION_WINDOW_MS, undefined, runtime.interpolatedNames);
1076
+ if (!step)
1077
+ return unavailableTopology();
1078
+ const contract = { runtimePlan, runtime, stackId, configSha256, batchId, observationWindow: window };
1079
+ return admitRuntimeTopology(contract, step, await input.execute(step));
1080
+ }
1081
+ function healthcheckEnabled(value) {
1082
+ if (value === undefined)
1083
+ return false;
1084
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
1085
+ return undefined;
1086
+ const healthcheck = value;
1087
+ if (Object.prototype.hasOwnProperty.call(healthcheck, 'disable') && typeof healthcheck.disable !== 'boolean')
1088
+ return undefined;
1089
+ if (Object.prototype.hasOwnProperty.call(healthcheck, 'test')) {
1090
+ if (!Array.isArray(healthcheck.test) || healthcheck.test.length > MAX_TOPOLOGY_SERVICES
1091
+ || healthcheck.test.some((part) => !(typeof part === 'string' && part.length <= OUTPUT_LIMIT_BYTES)
1092
+ && typeof part !== 'boolean' && !(typeof part === 'number' && Number.isFinite(part))))
1093
+ return undefined;
1094
+ }
1095
+ return healthcheck.disable !== true && !(Array.isArray(healthcheck.test) && healthcheck.test[0] === 'NONE');
1096
+ }
1097
+ function dependencyNames(value) {
1098
+ if (value === undefined)
1099
+ return [];
1100
+ if (Array.isArray(value)) {
1101
+ if (value.some((item) => typeof item !== 'string' || !SERVICE_ID.test(item)))
1102
+ return undefined;
1103
+ return [...new Set(value)].sort();
1104
+ }
1105
+ if (value === null || typeof value !== 'object')
1106
+ return undefined;
1107
+ const names = Object.keys(value);
1108
+ if (names.some((name) => !SERVICE_ID.test(name)))
1109
+ return undefined;
1110
+ return [...new Set(names)].sort();
1111
+ }
1112
+ function profileNames(value) {
1113
+ if (value === undefined)
1114
+ return [];
1115
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_SERVICES
1116
+ || value.some((name) => typeof name !== 'string' || !SERVICE_ID.test(name)))
1117
+ return undefined;
1118
+ return [...new Set(value)].sort();
1119
+ }
1120
+ function bindMountSources(value) {
1121
+ if (value === undefined)
1122
+ return [];
1123
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_EDGES)
1124
+ return undefined;
1125
+ const sources = [];
1126
+ for (const item of value) {
1127
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1128
+ return undefined;
1129
+ const volume = item;
1130
+ if (volume.type !== 'bind')
1131
+ continue;
1132
+ if (typeof volume.source !== 'string' || !isAbsolute(volume.source)
1133
+ || normalize(volume.source) !== volume.source || volume.source.includes('\0'))
1134
+ return undefined;
1135
+ sources.push(volume.source);
1136
+ }
1137
+ return [...new Set(sources)].sort();
1138
+ }
1139
+ function bindMounts(value) {
1140
+ if (value === undefined)
1141
+ return [];
1142
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_EDGES)
1143
+ return undefined;
1144
+ const mounts = [];
1145
+ for (const item of value) {
1146
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1147
+ return undefined;
1148
+ const volume = item;
1149
+ if (volume.type !== 'bind')
1150
+ continue;
1151
+ if (typeof volume.source !== 'string' || !isAbsolute(volume.source)
1152
+ || normalize(volume.source) !== volume.source || volume.source.includes('\0')
1153
+ || typeof volume.target !== 'string' || !CONTAINER_PATH.test(volume.target))
1154
+ return undefined;
1155
+ mounts.push({ source: volume.source, target: volume.target });
1156
+ }
1157
+ const identities = mounts.map(({ source, target }) => `${source}\0${target}`);
1158
+ if (new Set(identities).size !== identities.length)
1159
+ return undefined;
1160
+ return [...mounts].sort((left, right) => `${left.source}\0${left.target}`.localeCompare(`${right.source}\0${right.target}`));
1161
+ }
1162
+ const PUBLISHED_HOSTS = new Set(['127.0.0.1', '::1', '0.0.0.0', '::']);
1163
+ function composePublishedPorts(value) {
1164
+ if (value === undefined)
1165
+ return [];
1166
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_EDGES)
1167
+ return undefined;
1168
+ const ports = [];
1169
+ for (const item of value) {
1170
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1171
+ return undefined;
1172
+ const row = item;
1173
+ const target = row.target;
1174
+ const rawPublished = row.published;
1175
+ const published = typeof rawPublished === 'string' && /^[1-9]\d{0,4}$/.test(rawPublished)
1176
+ ? Number(rawPublished) : rawPublished;
1177
+ const hostIp = row.host_ip ?? '0.0.0.0';
1178
+ const protocol = row.protocol ?? 'tcp';
1179
+ if (!Number.isSafeInteger(target) || target < 1 || target > 65_535
1180
+ || !Number.isSafeInteger(published) || published < 1 || published > 65_535
1181
+ || typeof hostIp !== 'string' || !PUBLISHED_HOSTS.has(hostIp) || protocol !== 'tcp')
1182
+ return undefined;
1183
+ ports.push({
1184
+ hostIp: hostIp,
1185
+ target: target,
1186
+ published: published,
1187
+ protocol: 'tcp',
1188
+ });
1189
+ }
1190
+ const identities = ports.map(({ hostIp, target, published, protocol }) => `${hostIp}\0${published}\0${target}\0${protocol}`);
1191
+ if (new Set(identities).size !== identities.length)
1192
+ return undefined;
1193
+ return [...ports].sort((left, right) => `${left.hostIp}\0${left.published}\0${left.target}`.localeCompare(`${right.hostIp}\0${right.published}\0${right.target}`));
1194
+ }
1195
+ const RUNTIME_VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
1196
+ const CONTAINER_PATH = /^\/(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*[\\\x00-\x1f\x7f])\S+$/;
1197
+ function runtimeVolumeNames(value) {
1198
+ if (value === undefined)
1199
+ return new Map();
1200
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
1201
+ return undefined;
1202
+ const entries = Object.entries(value);
1203
+ if (entries.length > MAX_TOPOLOGY_EDGES)
1204
+ return undefined;
1205
+ const names = new Map();
1206
+ for (const [source, candidate] of entries) {
1207
+ if (!SERVICE_ID.test(source) || candidate === null || typeof candidate !== 'object' || Array.isArray(candidate))
1208
+ return undefined;
1209
+ const name = candidate.name;
1210
+ if (typeof name !== 'string' || !RUNTIME_VOLUME.test(name))
1211
+ return undefined;
1212
+ names.set(source, name);
1213
+ }
1214
+ return names;
1215
+ }
1216
+ function namedVolumeMounts(value, volumeNames) {
1217
+ if (value === undefined)
1218
+ return [];
1219
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_EDGES)
1220
+ return undefined;
1221
+ const mounts = [];
1222
+ for (const item of value) {
1223
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1224
+ return undefined;
1225
+ const volume = item;
1226
+ if (volume.type !== 'volume')
1227
+ continue;
1228
+ const source = volume.source;
1229
+ const target = volume.target;
1230
+ const name = typeof source === 'string' ? volumeNames.get(source) : undefined;
1231
+ if (typeof source !== 'string' || !SERVICE_ID.test(source) || !name
1232
+ || typeof target !== 'string' || !CONTAINER_PATH.test(target))
1233
+ return undefined;
1234
+ mounts.push({ source, name, target });
1235
+ }
1236
+ const identities = mounts.map(({ source, target }) => `${source}\0${target}`);
1237
+ if (new Set(identities).size !== identities.length)
1238
+ return undefined;
1239
+ return [...mounts].sort((left, right) => `${left.source}\0${left.target}`.localeCompare(`${right.source}\0${right.target}`));
1240
+ }
1241
+ // Compose prints labels as a mapping, but as a `KEY=VALUE` list once an override merges labels into
1242
+ // a service that already has some; reading only the mapping refused every such merged service.
1243
+ function composeLabels(value) {
1244
+ if (!Array.isArray(value))
1245
+ return value;
1246
+ const entries = [];
1247
+ for (const item of value) {
1248
+ if (typeof item !== 'string' || !item.includes('='))
1249
+ return null;
1250
+ entries.push([item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=') + 1)]);
1251
+ }
1252
+ const labels = Object.fromEntries(entries);
1253
+ return Object.keys(labels).length === entries.length ? labels : null;
1254
+ }
1255
+ function parseTopology(stdout, stackId, configSha256, activeProfiles) {
1256
+ try {
1257
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(stdout);
1258
+ const parsed = JSON.parse(source);
1259
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
1260
+ return undefined;
1261
+ const rawServices = parsed.services;
1262
+ if (rawServices === null || typeof rawServices !== 'object' || Array.isArray(rawServices))
1263
+ return undefined;
1264
+ const volumeNames = runtimeVolumeNames(parsed.volumes);
1265
+ if (!volumeNames)
1266
+ return undefined;
1267
+ const declaredNames = Object.keys(rawServices);
1268
+ if (declaredNames.length === 0 || declaredNames.length > MAX_TOPOLOGY_SERVICES || declaredNames.some((name) => !SERVICE_ID.test(name)))
1269
+ return undefined;
1270
+ const profiles = new Map();
1271
+ for (const name of declaredNames) {
1272
+ const raw = rawServices[name];
1273
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1274
+ return undefined;
1275
+ const selected = profileNames(raw.profiles);
1276
+ if (!selected)
1277
+ return undefined;
1278
+ profiles.set(name, selected);
1279
+ }
1280
+ const active = new Set(activeProfiles);
1281
+ const names = declaredNames.filter((name) => {
1282
+ const serviceProfiles = profiles.get(name) ?? [];
1283
+ return serviceProfiles.length === 0 || serviceProfiles.some((profile) => active.has(profile));
1284
+ });
1285
+ if (names.length === 0)
1286
+ return undefined;
1287
+ names.sort();
1288
+ const known = new Set(names);
1289
+ const byId = new Map();
1290
+ const runtimeHealth = new Map();
1291
+ let edgeCount = 0;
1292
+ for (const id of names) {
1293
+ const raw = rawServices[id];
1294
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1295
+ return undefined;
1296
+ const service = raw;
1297
+ const dependsOn = dependencyNames(service.depends_on);
1298
+ const mounts = bindMountSources(service.volumes);
1299
+ const exactBindMounts = bindMounts(service.volumes);
1300
+ const namedMounts = namedVolumeMounts(service.volumes, volumeNames);
1301
+ const publishedPorts = composePublishedPorts(service.ports);
1302
+ const healthcheck = healthcheckEnabled(service.healthcheck);
1303
+ const labels = composeLabels(service.labels);
1304
+ if (!dependsOn || !mounts || !exactBindMounts || !namedMounts || !publishedPorts || healthcheck === undefined || dependsOn.includes(id) || dependsOn.some((name) => !known.has(name))
1305
+ || labels === null || typeof labels !== 'object' || Array.isArray(labels)
1306
+ || labels['dev.b0dea.cockpit.stack-id'] !== stackId
1307
+ || labels['dev.b0dea.cockpit.config-sha256'] !== configSha256)
1308
+ return undefined;
1309
+ edgeCount += dependsOn.length + mounts.length + namedMounts.length + publishedPorts.length;
1310
+ if (edgeCount > MAX_TOPOLOGY_EDGES)
1311
+ return undefined;
1312
+ byId.set(id, {
1313
+ id,
1314
+ dependsOn,
1315
+ bindMountSources: mounts,
1316
+ namedVolumeMounts: namedMounts,
1317
+ healthcheck,
1318
+ });
1319
+ runtimeHealth.set(id, { bindMounts: exactBindMounts, publishedPorts });
1320
+ }
1321
+ const indegree = new Map(names.map((name) => [name, byId.get(name)?.dependsOn.length ?? 0]));
1322
+ const dependents = new Map(names.map((name) => [name, []]));
1323
+ for (const service of byId.values())
1324
+ for (const dependency of service.dependsOn)
1325
+ dependents.get(dependency)?.push(service.id);
1326
+ for (const values of dependents.values())
1327
+ values.sort();
1328
+ const ready = names.filter((name) => indegree.get(name) === 0);
1329
+ const order = [];
1330
+ while (ready.length > 0) {
1331
+ const next = ready.shift();
1332
+ if (next === undefined)
1333
+ return undefined;
1334
+ order.push(next);
1335
+ for (const dependent of dependents.get(next) ?? []) {
1336
+ const remaining = (indegree.get(dependent) ?? 0) - 1;
1337
+ indegree.set(dependent, remaining);
1338
+ if (remaining === 0) {
1339
+ ready.push(dependent);
1340
+ ready.sort();
1341
+ }
1342
+ }
1343
+ }
1344
+ if (order.length !== names.length)
1345
+ return undefined;
1346
+ return { services: names.map((name) => byId.get(name)), order, byId, runtimeHealth };
1347
+ }
1348
+ catch {
1349
+ // A malformed declaration shape yields no topology, which the caller reports as unavailable.
1350
+ return undefined;
1351
+ }
1352
+ }
1353
+ function mountSourcesMatch(binds, declared) {
1354
+ const { missing, unexpected } = compareBindSources(binds, declared);
1355
+ return missing.length === 0 && unexpected.length === 0;
1356
+ }
1357
+ function admitRuntimeTopology(contract, step, outcome) {
1358
+ const unavailable = () => unavailableTopology();
1359
+ if (outcome.kind !== 'complete' || outcome.stderr.byteLength !== 0 || !outcome.receipt)
1360
+ return unavailable();
1361
+ const { startedAt, finishedAt } = outcome.receipt;
1362
+ const windowStart = Date.parse(contract.observationWindow.startedAt);
1363
+ const windowEnd = Date.parse(contract.observationWindow.expiresAt);
1364
+ const receiptStart = Date.parse(startedAt);
1365
+ const receiptEnd = Date.parse(finishedAt);
1366
+ const now = Date.now();
1367
+ if (!isCanonicalTimestamp(startedAt) || !isCanonicalTimestamp(finishedAt)
1368
+ || windowStart > receiptStart || receiptStart > receiptEnd || receiptEnd > now || receiptEnd > windowEnd
1369
+ || now < windowStart || now > windowEnd)
1370
+ return unavailable();
1371
+ const projected = parseTopology(outcome.stdout, contract.stackId, contract.configSha256, contract.runtime.activeProfiles);
1372
+ if (!projected)
1373
+ return unavailable();
1374
+ const mounts = compareBindSources(projected.services.flatMap(({ bindMountSources }) => bindMountSources), contract.runtime.expectedMountSources);
1375
+ // The caller can name the sibling or host path behind each difference; a bare refusal here
1376
+ // left `up` reporting only that readiness authority was unavailable.
1377
+ if (mounts.missing.length > 0 || mounts.unexpected.length > 0)
1378
+ return {
1379
+ kind: 'unavailable',
1380
+ reason: 'the bind mounts Compose resolves differ from the declared mount sources',
1381
+ mounts,
1382
+ };
1383
+ const topology = {
1384
+ stackId: contract.stackId,
1385
+ configSha256: contract.configSha256,
1386
+ batchId: contract.batchId,
1387
+ runtimeVersion: contract.runtime.runtimeVersion,
1388
+ composeVersion: contract.runtime.composeVersion,
1389
+ executableSha256: contract.runtime.runtimeExecutable.sha256,
1390
+ projectName: contract.runtime.projectName,
1391
+ cwd: contract.runtime.cwd,
1392
+ observationWindow: contract.observationWindow,
1393
+ services: projected.services,
1394
+ };
1395
+ issuedTopologyEvidence.set(topology, {
1396
+ ...contract,
1397
+ step,
1398
+ order: projected.order,
1399
+ services: projected.byId,
1400
+ runtimeHealth: projected.runtimeHealth,
1401
+ });
1402
+ const observations = [
1403
+ issueObservation({
1404
+ code: ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE,
1405
+ owner: OWNER,
1406
+ stackId: contract.stackId,
1407
+ source: step.argv0,
1408
+ observed: { state: 'available', value: true, observedAt: finishedAt },
1409
+ expectation: { kind: 'equal', value: true },
1410
+ applicability: { state: 'required', posture: 'mounted' },
1411
+ freshness: { state: 'current' },
1412
+ remedy: 'restore the declared runtime executable and retry the bounded probe',
1413
+ }),
1414
+ issueObservation({
1415
+ code: ENVIRONMENT_CODES.MOUNT_POSTURE_UNAVAILABLE,
1416
+ owner: OWNER,
1417
+ stackId: contract.stackId,
1418
+ source: step.id,
1419
+ observed: { state: 'available', value: true, observedAt: finishedAt },
1420
+ expectation: { kind: 'equal', value: true },
1421
+ applicability: { state: 'required', posture: 'mounted' },
1422
+ freshness: { state: 'current' },
1423
+ remedy: 'restore every declared bind source and refuse undeclared host binds',
1424
+ }),
1425
+ ];
1426
+ return { kind: 'complete', step, topology, observations };
1427
+ }
1428
+ export function isRuntimeTopologyEvidence(value) {
1429
+ if (value === null || typeof value !== 'object')
1430
+ return false;
1431
+ const contract = issuedTopologyEvidence.get(value);
1432
+ const now = Date.now();
1433
+ return contract !== undefined && Date.parse(contract.observationWindow.startedAt) <= now
1434
+ && now <= Date.parse(contract.observationWindow.expiresAt);
1435
+ }
1436
+ export function resolveRuntimeServiceOrder(topology) {
1437
+ const contract = topology !== null && typeof topology === 'object' ? issuedTopologyEvidence.get(topology) : undefined;
1438
+ const now = Date.now();
1439
+ return contract && Date.parse(contract.observationWindow.startedAt) <= now
1440
+ && now <= Date.parse(contract.observationWindow.expiresAt) ? [...contract.order] : [];
1441
+ }
1442
+ export function projectRuntimeExecutionEnvironment(topology, namespace) {
1443
+ const contract = topology !== null && typeof topology === 'object' ? issuedTopologyEvidence.get(topology) : undefined;
1444
+ if (namespace !== undefined && (!contract?.runtime.portLeaseProjection
1445
+ || !inspectPortLeaseProjection(contract.runtime.portLeaseProjection, namespace)))
1446
+ return undefined;
1447
+ const now = Date.now();
1448
+ return contract && Date.parse(contract.observationWindow.startedAt) <= now
1449
+ && now <= Date.parse(contract.observationWindow.expiresAt)
1450
+ ? runtimeCommandEnvironment(contract.runtime.env, contract.runtime.interpolatedNames)
1451
+ : undefined;
1452
+ }
1453
+ function projectRuntimeExecutionStep(step, slot) {
1454
+ const argv = step.argv.map((value, index) => slot?.index === index
1455
+ ? { kind: 'runtime-container-id', service: slot.service }
1456
+ : value);
1457
+ if (argv.length === 0 || slot && (slot.index < 0 || slot.index >= argv.length))
1458
+ return undefined;
1459
+ const compensation = step.compensation.map((item) => projectRuntimeExecutionStep(item));
1460
+ if (compensation.some((item) => item === undefined))
1461
+ return undefined;
1462
+ return {
1463
+ argv: argv,
1464
+ argv0: step.argv0,
1465
+ cwd: step.cwd,
1466
+ env: { ...step.env },
1467
+ timeoutCeilingMs: step.timeoutMs,
1468
+ stdoutLimitBytes: step.stdoutLimitBytes,
1469
+ stderrLimitBytes: step.stderrLimitBytes,
1470
+ paths: [...step.paths],
1471
+ ports: [...step.ports],
1472
+ expected: step.expected.map((item) => ({ ...item })),
1473
+ consumedFiles: step.consumedFiles.map((item) => ({ ...item, argvIndexes: [...item.argvIndexes] })),
1474
+ filesystemChecks: step.filesystemChecks.map((item) => ({ ...item })),
1475
+ compensation: compensation,
1476
+ };
1477
+ }
1478
+ // Observations, evidence and failures a service step produced, which failure logs, the lifecycle
1479
+ // planner and the health readers take back by identity.
1480
+ const issuedRuntimeServiceObservations = new WeakMap();
1481
+ const consumedRuntimeServiceFailureObservations = new WeakSet();
1482
+ const issuedRuntimeServiceEvidence = new WeakMap();
1483
+ const issuedRuntimeServiceFailures = new WeakMap();
1484
+ function runtimeServiceLookupSteps(topologyContract, service, issuanceId) {
1485
+ const status = derivedComposeStep(topologyContract.step, `runtime:docker-compose:status:${service.id}`, 'ps', ['ps', '--format', 'json', '--all', service.id], OUTPUT_LIMIT_BYTES, issuanceId, MAX_OBSERVATION_WINDOW_MS, undefined, topologyContract.runtime.interpolatedNames);
1486
+ const identity = derivedComposeStep(topologyContract.step, `runtime:docker-compose:identity:${service.id}`, 'ps', ['ps', '--quiet', '--all', service.id], 128, issuanceId, MAX_OBSERVATION_WINDOW_MS, undefined, topologyContract.runtime.interpolatedNames);
1487
+ return status && identity ? { status, identity } : undefined;
1488
+ }
1489
+ function runtimeServiceInitialSteps(topologyContract, service, issuanceId) {
1490
+ // `up --wait` returns only once the service runs and its healthcheck passes, after Compose has
1491
+ // built or pulled its image, and the declared cold_start_seconds bounds exactly that. The
1492
+ // ordinary command budget does not: a first `up` was killed and reported "did not become
1493
+ // healthy" seconds before its container turned healthy. When the service's configuration
1494
+ // changed since its container was created, `up` first stops that container and pays its stop
1495
+ // grace; a start without that grace was killed mid-recreate, leaving the replacement container
1496
+ // created but never started.
1497
+ const start = derivedComposeStep({ ...topologyContract.step, timeoutMs: COMPOSE_STOP_GRACE_MS
1498
+ + Math.max(topologyContract.step.timeoutMs, topologyContract.runtime.coldStartLimitMillis) }, `runtime:docker-compose:start:${service.id}`, 'up', ['--progress', 'quiet', 'up', '--detach', '--wait', '--no-deps', service.id], OUTPUT_LIMIT_BYTES, issuanceId, MAX_OBSERVATION_WINDOW_MS, undefined, topologyContract.runtime.interpolatedNames);
1499
+ // A service that failed its start under a restart policy restarts forever until `down`. The
1500
+ // failed `up` stops it and keeps the container, so its logs stay readable and the next `up`
1501
+ // starts it again.
1502
+ const stop = derivedComposeStep({ ...topologyContract.step, timeoutMs: COMPOSE_STOP_GRACE_MS + topologyContract.step.timeoutMs }, `runtime:docker-compose:stop:${service.id}`, 'stop', ['--progress', 'quiet', 'stop', service.id], OUTPUT_LIMIT_BYTES, issuanceId, MAX_OBSERVATION_WINDOW_MS, undefined, topologyContract.runtime.interpolatedNames);
1503
+ const lookup = runtimeServiceLookupSteps(topologyContract, service, issuanceId);
1504
+ return start && stop && lookup ? { start, stop, ...lookup } : undefined;
1505
+ }
1506
+ function runtimeServiceContract(topology, serviceId) {
1507
+ const topologyContract = issuedTopologyEvidence.get(topology);
1508
+ const service = topologyContract?.services.get(serviceId);
1509
+ const now = Date.now();
1510
+ return topologyContract && service && SERVICE_ID.test(serviceId)
1511
+ && now >= Date.parse(topologyContract.observationWindow.startedAt)
1512
+ && now <= Date.parse(topologyContract.observationWindow.expiresAt)
1513
+ ? { topology, topologyContract, service } : undefined;
1514
+ }
1515
+ function classifyComposeStatus(stdout, projectName, service) {
1516
+ if (stdout.byteLength === 0)
1517
+ return { kind: 'empty' };
1518
+ try {
1519
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(stdout);
1520
+ const lines = source.split('\n');
1521
+ if (lines.at(-1) === '')
1522
+ lines.pop();
1523
+ if (lines.length !== 1 || lines[0]?.length === 0)
1524
+ return { kind: 'malformed' };
1525
+ const parsed = JSON.parse(lines[0]);
1526
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
1527
+ return { kind: 'malformed' };
1528
+ const value = parsed;
1529
+ if (typeof value.ID !== 'string' || !SHORT_CONTAINER_ID.test(value.ID)
1530
+ || value.Project !== projectName || value.Service !== service)
1531
+ return { kind: 'malformed' };
1532
+ return { kind: 'valid', value: value.ID };
1533
+ }
1534
+ catch {
1535
+ // Unparsable output is the malformed verdict.
1536
+ return { kind: 'malformed' };
1537
+ }
1538
+ }
1539
+ function parseComposeStatus(stdout, projectName, service) {
1540
+ const result = classifyComposeStatus(stdout, projectName, service);
1541
+ return result.kind === 'valid' ? result.value : undefined;
1542
+ }
1543
+ function classifyContainerIdentity(stdout, shortId) {
1544
+ if (stdout.byteLength === 0)
1545
+ return { kind: 'empty' };
1546
+ try {
1547
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(stdout);
1548
+ const lines = source.split('\n');
1549
+ if (lines.at(-1) === '')
1550
+ lines.pop();
1551
+ if (lines.length !== 1 || !CONTAINER_ID.test(lines[0] ?? '') || !lines[0]?.startsWith(shortId))
1552
+ return { kind: 'malformed' };
1553
+ return { kind: 'valid', value: lines[0] };
1554
+ }
1555
+ catch {
1556
+ // Undecodable output is the malformed verdict.
1557
+ return { kind: 'malformed' };
1558
+ }
1559
+ }
1560
+ function parseContainerIdentity(stdout, shortId) {
1561
+ const result = classifyContainerIdentity(stdout, shortId);
1562
+ return result.kind === 'valid' ? result.value : undefined;
1563
+ }
1564
+ function parseRuntimeServiceLookup(status, identity, projectName, service) {
1565
+ if (status.byteLength === 0)
1566
+ return identity.byteLength === 0 ? { kind: 'missing' } : undefined;
1567
+ const shortId = parseComposeStatus(status, projectName, service);
1568
+ const containerId = shortId ? parseContainerIdentity(identity, shortId) : undefined;
1569
+ return shortId && containerId ? { kind: 'present', shortId, containerId } : undefined;
1570
+ }
1571
+ function receiptWithinWindow(outcome, window, now) {
1572
+ const startedAt = outcome.receipt.startedAt;
1573
+ const finishedAt = outcome.receipt.finishedAt;
1574
+ const windowStart = Date.parse(window.startedAt);
1575
+ const windowEnd = Date.parse(window.expiresAt);
1576
+ const receiptStart = Date.parse(startedAt);
1577
+ const receiptEnd = Date.parse(finishedAt);
1578
+ return isCanonicalTimestamp(startedAt) && isCanonicalTimestamp(finishedAt)
1579
+ && windowStart <= receiptStart && receiptStart <= receiptEnd && receiptEnd <= now
1580
+ && receiptEnd <= windowEnd && windowStart <= now && now <= windowEnd;
1581
+ }
1582
+ // `.Id` is a key of the inspect JSON, not of Docker's typed result, so Docker runs this template on
1583
+ // the raw JSON. There a container without a healthcheck has no State.Health key, and a field access
1584
+ // on a missing key fails the whole inspect; `index` yields nothing instead.
1585
+ const CONTAINER_INSPECT_FORMAT = '{"ID":{{json .Id}},"State":{{json .State.Status}},"Health":{{with index .State "Health"}}{{json .Status}}{{else}}""{{end}},"Labels":{"dev.b0dea.cockpit.stack-id":{{json (index .Config.Labels "dev.b0dea.cockpit.stack-id")}},"dev.b0dea.cockpit.config-sha256":{{json (index .Config.Labels "dev.b0dea.cockpit.config-sha256")}},"com.docker.compose.project":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}}},"Mounts":{{json .Mounts}},"Ports":{{json .NetworkSettings.Ports}}}';
1586
+ function runtimeServiceInspectStep(topologyContract, service, containerId, issuanceId, timeoutMs) {
1587
+ const executable = topologyContract.runtime.runtimeExecutable;
1588
+ return snapshotExecutionPlan({ steps: [{
1589
+ id: `runtime:docker-container:health:${service.id}`,
1590
+ argv: [executable.canonicalPath, 'container', 'inspect', '--format', CONTAINER_INSPECT_FORMAT, containerId],
1591
+ argv0: executable.invocationPath,
1592
+ cwd: topologyContract.runtime.cwd,
1593
+ env: runtimeCommandEnvironment(topologyContract.runtime.env, topologyContract.runtime.interpolatedNames),
1594
+ timeoutMs,
1595
+ stdoutLimitBytes: OUTPUT_LIMIT_BYTES,
1596
+ stderrLimitBytes: OUTPUT_LIMIT_BYTES,
1597
+ paths: [],
1598
+ ports: [],
1599
+ labels: {
1600
+ stackId: topologyContract.stackId,
1601
+ configSha256: topologyContract.configSha256,
1602
+ batchId: topologyContract.batchId,
1603
+ service: service.id,
1604
+ containerId,
1605
+ runtimeVersion: topologyContract.runtime.runtimeVersion,
1606
+ composeVersion: topologyContract.runtime.composeVersion,
1607
+ executableSha256: executable.sha256,
1608
+ projectName: topologyContract.runtime.projectName,
1609
+ issuanceId,
1610
+ },
1611
+ expected: [],
1612
+ consumedFiles: [{
1613
+ path: executable.canonicalPath,
1614
+ sha256: executable.sha256,
1615
+ maxBytes: 536_870_912,
1616
+ argvIndexes: [0],
1617
+ executable: true,
1618
+ executionMode: executable.executionMode,
1619
+ }],
1620
+ filesystemChecks: [],
1621
+ compensation: [],
1622
+ }] })?.steps[0];
1623
+ }
1624
+ export function projectRuntimeLifecycle(topology) {
1625
+ const contract = issuedTopologyEvidence.get(topology);
1626
+ const now = Date.now();
1627
+ if (!contract || now < Date.parse(contract.observationWindow.startedAt)
1628
+ || now > Date.parse(contract.observationWindow.expiresAt))
1629
+ return undefined;
1630
+ const services = [];
1631
+ for (const serviceId of contract.order) {
1632
+ const service = contract.services.get(serviceId);
1633
+ if (!service)
1634
+ return undefined;
1635
+ const initial = runtimeServiceInitialSteps(contract, service, randomBytes(32).toString('hex'));
1636
+ const inspect = runtimeServiceInspectStep(contract, service, '0'.repeat(64), randomBytes(32).toString('hex'), Math.min(contract.step.timeoutMs, MAX_OBSERVATION_WINDOW_MS));
1637
+ if (!initial || !inspect)
1638
+ return undefined;
1639
+ const start = projectRuntimeExecutionStep(initial.start);
1640
+ const status = projectRuntimeExecutionStep(initial.status);
1641
+ const identity = projectRuntimeExecutionStep(initial.identity);
1642
+ const projectedInspect = projectRuntimeExecutionStep(inspect, { index: inspect.argv.length - 1, service: service.id });
1643
+ if (!start || !status || !identity || !projectedInspect)
1644
+ return undefined;
1645
+ services.push({
1646
+ id: service.id,
1647
+ start,
1648
+ status,
1649
+ identity,
1650
+ inspect: projectedInspect,
1651
+ healthAcceptance: { state: 'running', health: service.healthcheck ? 'healthy' : null },
1652
+ });
1653
+ }
1654
+ const semantics = {
1655
+ stackId: contract.stackId,
1656
+ configSha256: contract.configSha256,
1657
+ runtimeVersion: contract.runtime.runtimeVersion,
1658
+ composeVersion: contract.runtime.composeVersion,
1659
+ projectName: contract.runtime.projectName,
1660
+ topology: {
1661
+ order: [...contract.order],
1662
+ services: topology.services.map((service) => ({
1663
+ id: service.id,
1664
+ dependsOn: [...service.dependsOn],
1665
+ bindMountSources: [...service.bindMountSources],
1666
+ namedVolumeMounts: service.namedVolumeMounts?.map((mount) => ({ ...mount })) ?? [],
1667
+ healthcheck: service.healthcheck,
1668
+ })),
1669
+ },
1670
+ runtimeChecks: {
1671
+ coldStartLimitMillis: contract.runtime.coldStartLimitMillis,
1672
+ expectedMountSources: [...contract.runtime.expectedMountSources],
1673
+ },
1674
+ services,
1675
+ };
1676
+ if (Buffer.byteLength(JSON.stringify(semantics), 'utf8') > MAX_TOPOLOGY_BYTES)
1677
+ return undefined;
1678
+ return { topology, semantics };
1679
+ }
1680
+ const CONTAINER_STATES = new Set(['created', 'running', 'restarting', 'paused', 'exited', 'dead', 'removing']);
1681
+ const CONTAINER_HEALTH = new Set(['', 'healthy', 'starting', 'unhealthy']);
1682
+ function inspectedBindMounts(value) {
1683
+ if (!Array.isArray(value) || value.length > MAX_TOPOLOGY_EDGES)
1684
+ return undefined;
1685
+ const mounts = [];
1686
+ for (const item of value) {
1687
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1688
+ return undefined;
1689
+ const row = item;
1690
+ if (row.Type !== 'bind')
1691
+ continue;
1692
+ if (typeof row.Source !== 'string' || !isAbsolute(row.Source) || normalize(row.Source) !== row.Source
1693
+ || row.Source.includes('\0') || typeof row.Destination !== 'string' || !CONTAINER_PATH.test(row.Destination))
1694
+ return undefined;
1695
+ mounts.push({ source: row.Source, target: row.Destination });
1696
+ }
1697
+ const identities = mounts.map(({ source, target }) => `${source}\0${target}`);
1698
+ if (new Set(identities).size !== identities.length)
1699
+ return undefined;
1700
+ return [...mounts].sort((left, right) => `${left.source}\0${left.target}`.localeCompare(`${right.source}\0${right.target}`));
1701
+ }
1702
+ function inspectedPublishedPorts(value) {
1703
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
1704
+ return undefined;
1705
+ const entries = Object.entries(value);
1706
+ if (entries.length > MAX_TOPOLOGY_EDGES)
1707
+ return undefined;
1708
+ const ports = [];
1709
+ for (const [key, bindings] of entries) {
1710
+ const match = key.match(/^([1-9]\d{0,4})\/(tcp|udp)$/);
1711
+ if (!match || Number(match[1]) > 65_535)
1712
+ return undefined;
1713
+ if (bindings === null)
1714
+ continue;
1715
+ if (!Array.isArray(bindings) || bindings.length > MAX_TOPOLOGY_EDGES)
1716
+ return undefined;
1717
+ if (match[2] !== 'tcp')
1718
+ return undefined;
1719
+ for (const binding of bindings) {
1720
+ if (binding === null || typeof binding !== 'object' || Array.isArray(binding))
1721
+ return undefined;
1722
+ const row = binding;
1723
+ const published = typeof row.HostPort === 'string' && /^[1-9]\d{0,4}$/.test(row.HostPort)
1724
+ ? Number(row.HostPort) : undefined;
1725
+ if (typeof row.HostIp !== 'string' || !PUBLISHED_HOSTS.has(row.HostIp)
1726
+ || published === undefined || published > 65_535)
1727
+ return undefined;
1728
+ ports.push({
1729
+ hostIp: row.HostIp,
1730
+ target: Number(match[1]),
1731
+ published,
1732
+ protocol: 'tcp',
1733
+ });
1734
+ }
1735
+ }
1736
+ const identities = ports.map(({ hostIp, target, published }) => `${hostIp}\0${published}\0${target}`);
1737
+ if (new Set(identities).size !== identities.length)
1738
+ return undefined;
1739
+ return [...ports].sort((left, right) => `${left.hostIp}\0${left.published}\0${left.target}`.localeCompare(`${right.hostIp}\0${right.published}\0${right.target}`));
1740
+ }
1741
+ function parseRuntimeServiceInspection(stdout, contract) {
1742
+ try {
1743
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(stdout);
1744
+ const parsed = JSON.parse(source);
1745
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
1746
+ return undefined;
1747
+ const value = parsed;
1748
+ const labels = value.Labels;
1749
+ const mounts = inspectedBindMounts(value.Mounts);
1750
+ const ports = inspectedPublishedPorts(value.Ports);
1751
+ if (value.ID !== contract.containerId || typeof value.State !== 'string' || typeof value.Health !== 'string'
1752
+ || !CONTAINER_STATES.has(value.State) || !CONTAINER_HEALTH.has(value.Health)
1753
+ || labels === null || typeof labels !== 'object' || Array.isArray(labels)
1754
+ || labels['com.docker.compose.project'] !== contract.topologyContract.runtime.projectName
1755
+ || labels['com.docker.compose.service'] !== contract.service.id
1756
+ || labels['dev.b0dea.cockpit.config-sha256'] !== contract.topologyContract.configSha256
1757
+ || labels['dev.b0dea.cockpit.stack-id'] !== contract.topologyContract.stackId
1758
+ || !contract.service.healthcheck && value.Health !== '' || !mounts || !ports)
1759
+ return undefined;
1760
+ return {
1761
+ containerId: value.ID,
1762
+ state: value.State,
1763
+ health: value.Health === '' ? null : value.Health,
1764
+ bindMounts: mounts,
1765
+ publishedPorts: ports,
1766
+ };
1767
+ }
1768
+ catch {
1769
+ // A malformed inspection shape yields no observation, which the caller reports as unavailable.
1770
+ return undefined;
1771
+ }
1772
+ }
1773
+ async function inspectRuntimeService(contract, execute) {
1774
+ const window = contract.topologyContract.observationWindow;
1775
+ const step = runtimeServiceInspectStep(contract.topologyContract, contract.service, contract.containerId, randomBytes(32).toString('hex'), Math.min(contract.topologyContract.step.timeoutMs, Math.max(1, Date.parse(window.expiresAt) - Date.now())));
1776
+ if (!step)
1777
+ return { kind: 'failure' };
1778
+ const outcome = await execute(step);
1779
+ const value = outcome.kind === 'complete' && outcome.stderr.byteLength === 0
1780
+ ? parseRuntimeServiceInspection(outcome.stdout, contract) : undefined;
1781
+ if (!value)
1782
+ return runtimeServiceFailure(contract, 'inspect', outcome, contract.identityFinishedAt, contract.containerId);
1783
+ if (!receiptWithinWindow(outcome, window, Date.now())
1784
+ || Date.parse(contract.identityFinishedAt) > Date.parse(outcome.receipt.startedAt))
1785
+ return { kind: 'failure' };
1786
+ const observation = {
1787
+ stackId: contract.topologyContract.stackId,
1788
+ configSha256: contract.topologyContract.configSha256,
1789
+ batchId: contract.topologyContract.batchId,
1790
+ runtimeVersion: contract.topologyContract.runtime.runtimeVersion,
1791
+ composeVersion: contract.topologyContract.runtime.composeVersion,
1792
+ executableSha256: contract.topologyContract.runtime.runtimeExecutable.sha256,
1793
+ projectName: contract.topologyContract.runtime.projectName,
1794
+ service: contract.service.id,
1795
+ containerId: value.containerId,
1796
+ state: value.state,
1797
+ health: value.health,
1798
+ observedAt: outcome.receipt.finishedAt,
1799
+ observationWindow: window,
1800
+ };
1801
+ issuedRuntimeServiceObservations.set(observation, { ...contract, inspection: value });
1802
+ return { kind: 'observed', observation };
1803
+ }
1804
+ /** The step's failure as log evidence, or a failure without logs when its timing cannot be placed. */
1805
+ function runtimeServiceFailure(contract, stage, outcome, previousFinishedAt, containerId, stop) {
1806
+ const observedAt = failedBoundaryObservedAt(outcome, contract.topologyContract.observationWindow, previousFinishedAt);
1807
+ const context = observedAt ? runtimeServiceFailureContext(contract, stage, observedAt, containerId) : undefined;
1808
+ if (!context)
1809
+ return { kind: 'failure', ...stop ? { stop } : {} };
1810
+ const logs = {};
1811
+ issuedRuntimeServiceFailures.set(logs, context);
1812
+ return { kind: 'failure', logs, ...stop ? { stop } : {} };
1813
+ }
1814
+ /**
1815
+ * Starts one service with `up --wait`, finds its container through Compose and inspects it. Each
1816
+ * step must finish inside the topology's observation window and after the one before it.
1817
+ */
1818
+ export async function startRuntimeService(input) {
1819
+ const contract = runtimeServiceContract(input.topology, input.service);
1820
+ const steps = contract && runtimeServiceInitialSteps(contract.topologyContract, contract.service, randomBytes(32).toString('hex'));
1821
+ if (!contract || !steps)
1822
+ return { kind: 'failure' };
1823
+ const { projectName } = contract.topologyContract.runtime;
1824
+ const window = contract.topologyContract.observationWindow;
1825
+ const clean = (outcome, after) => outcome.kind === 'complete' && outcome.stderr.byteLength === 0 && receiptWithinWindow(outcome, window, Date.now())
1826
+ && (after === undefined || Date.parse(after) <= Date.parse(outcome.receipt.startedAt));
1827
+ const failed = (stage, outcome, after) => runtimeServiceFailure(contract, stage, outcome, after, null, steps.stop);
1828
+ const start = await input.execute(steps.start);
1829
+ if (start.kind === 'refusal' || start.stderr.byteLength !== 0)
1830
+ return failed('start', start);
1831
+ if (!clean(start))
1832
+ return { kind: 'failure', stop: steps.stop };
1833
+ const status = await input.execute(steps.status);
1834
+ const shortId = status.kind === 'complete' && status.stderr.byteLength === 0
1835
+ ? classifyComposeStatus(status.stdout, projectName, contract.service.id) : { kind: 'empty' };
1836
+ if (shortId.kind === 'empty')
1837
+ return failed('status', status, start.receipt.finishedAt);
1838
+ if (shortId.kind === 'malformed' || !clean(status, start.receipt.finishedAt))
1839
+ return { kind: 'failure', stop: steps.stop };
1840
+ const identity = await input.execute(steps.identity);
1841
+ const containerId = identity.kind === 'complete' && identity.stderr.byteLength === 0
1842
+ ? classifyContainerIdentity(identity.stdout, shortId.value) : { kind: 'empty' };
1843
+ if (containerId.kind === 'empty')
1844
+ return failed('identity', identity, status.receipt.finishedAt);
1845
+ if (containerId.kind === 'malformed' || !clean(identity, status.receipt.finishedAt))
1846
+ return { kind: 'failure', stop: steps.stop };
1847
+ const inspected = await inspectRuntimeService({
1848
+ ...contract, containerId: containerId.value,
1849
+ startStartedAt: start.receipt.startedAt, identityFinishedAt: identity.receipt.finishedAt,
1850
+ }, input.execute);
1851
+ return inspected.kind === 'stale' ? inspected : { ...inspected, stop: steps.stop };
1852
+ }
1853
+ /**
1854
+ * Re-reads a service an earlier run started: stale when Compose no longer has the recorded
1855
+ * container, otherwise the container's current inspection.
1856
+ */
1857
+ export async function revalidateRuntimeService(input) {
1858
+ const contract = runtimeServiceContract(input.topology, input.service);
1859
+ const steps = contract && CONTAINER_ID.test(input.expectedContainerId)
1860
+ ? runtimeServiceLookupSteps(contract.topologyContract, contract.service, randomBytes(32).toString('hex')) : undefined;
1861
+ if (!contract || !steps)
1862
+ return { kind: 'failure' };
1863
+ const { projectName } = contract.topologyContract.runtime;
1864
+ const window = contract.topologyContract.observationWindow;
1865
+ const status = await input.execute(steps.status);
1866
+ if (status.kind !== 'complete' || status.stderr.byteLength !== 0 || !receiptWithinWindow(status, window, Date.now()))
1867
+ return { kind: 'failure' };
1868
+ const shortId = classifyComposeStatus(status.stdout, projectName, contract.service.id);
1869
+ if (shortId.kind === 'malformed')
1870
+ return { kind: 'failure' };
1871
+ if (shortId.kind === 'empty' || !input.expectedContainerId.startsWith(shortId.value))
1872
+ return { kind: 'stale' };
1873
+ const identity = await input.execute(steps.identity);
1874
+ if (identity.kind !== 'complete' || identity.stderr.byteLength !== 0 || !receiptWithinWindow(identity, window, Date.now())
1875
+ || Date.parse(status.receipt.finishedAt) > Date.parse(identity.receipt.startedAt))
1876
+ return { kind: 'failure' };
1877
+ const observed = parseRuntimeServiceLookup(status.stdout, identity.stdout, projectName, contract.service.id);
1878
+ if (!observed)
1879
+ return { kind: 'failure' };
1880
+ if (observed.kind === 'missing' || observed.containerId !== input.expectedContainerId)
1881
+ return { kind: 'stale' };
1882
+ return inspectRuntimeService({ ...contract, containerId: observed.containerId, identityFinishedAt: identity.receipt.finishedAt }, input.execute);
1883
+ }
1884
+ /** The observation as healthy-service evidence, or undefined when the service is not running healthy. */
1885
+ export function admitRuntimeServiceHealth(observation) {
1886
+ const contract = issuedRuntimeServiceObservations.get(observation);
1887
+ const now = Date.now();
1888
+ if (!contract || now < Date.parse(contract.topologyContract.observationWindow.startedAt)
1889
+ || now > Date.parse(contract.topologyContract.observationWindow.expiresAt)
1890
+ || observation.state !== 'running'
1891
+ || (contract.service.healthcheck ? observation.health !== 'healthy' : observation.health !== null))
1892
+ return undefined;
1893
+ const evidence = {
1894
+ stackId: observation.stackId,
1895
+ configSha256: observation.configSha256,
1896
+ batchId: observation.batchId,
1897
+ runtimeVersion: observation.runtimeVersion,
1898
+ composeVersion: observation.composeVersion,
1899
+ executableSha256: observation.executableSha256,
1900
+ projectName: observation.projectName,
1901
+ service: observation.service,
1902
+ containerId: observation.containerId,
1903
+ state: 'running',
1904
+ health: observation.health === 'healthy' ? 'healthy' : null,
1905
+ observedAt: observation.observedAt,
1906
+ observationWindow: observation.observationWindow,
1907
+ };
1908
+ issuedRuntimeServiceEvidence.set(evidence, contract);
1909
+ return evidence;
1910
+ }
1911
+ export function isRuntimeServiceEvidence(value) {
1912
+ if (value === null || typeof value !== 'object')
1913
+ return false;
1914
+ const contract = issuedRuntimeServiceEvidence.get(value);
1915
+ const now = Date.now();
1916
+ return contract !== undefined && Date.parse(contract.topologyContract.observationWindow.startedAt) <= now
1917
+ && now <= Date.parse(contract.topologyContract.observationWindow.expiresAt);
1918
+ }
1919
+ export function observeRuntimeMountMaterialization(input) {
1920
+ const captured = captureOwnValues(input, ['topology', 'services', 'observedAt']);
1921
+ const topologyValue = captured?.topology;
1922
+ const topology = topologyValue && typeof topologyValue === 'object' ? issuedTopologyEvidence.get(topologyValue) : undefined;
1923
+ const observedAt = captured?.observedAt;
1924
+ if (!topology)
1925
+ throw new RefusalError('runtime mount evidence is not authentic');
1926
+ const fail = (reason) => issueObservation({
1927
+ code: ENVIRONMENT_CODES.MOUNT_MATERIALIZATION_UNAVAILABLE,
1928
+ owner: OWNER,
1929
+ stackId: topology.stackId,
1930
+ source: topology.runtime.projectName,
1931
+ observed: { state: 'unavailable', reason, observedAt: typeof observedAt === 'string' && isCanonicalTimestamp(observedAt) ? observedAt : new Date().toISOString() },
1932
+ expectation: { kind: 'equal', value: true },
1933
+ applicability: { state: 'required', posture: 'running-stack' },
1934
+ freshness: { state: 'current' },
1935
+ remedy: 'restore the exact declared bind mounts and re-observe every runtime service',
1936
+ });
1937
+ if (!Array.isArray(captured?.services) || captured.services.length !== topology.services.size
1938
+ || typeof observedAt !== 'string' || !isCanonicalTimestamp(observedAt)) {
1939
+ return fail('runtime mount evidence is incomplete or unauthenticated');
1940
+ }
1941
+ const now = Date.now();
1942
+ if (now < Date.parse(topology.observationWindow.startedAt) || now > Date.parse(topology.observationWindow.expiresAt)
1943
+ || Date.parse(observedAt) < Date.parse(topology.observationWindow.startedAt)
1944
+ || Date.parse(observedAt) > Math.min(now, Date.parse(topology.observationWindow.expiresAt))) {
1945
+ return fail('runtime mount evidence is outside its current observation window');
1946
+ }
1947
+ const seen = new Set();
1948
+ for (const evidence of captured.services) {
1949
+ const contract = evidence && typeof evidence === 'object' ? issuedRuntimeServiceEvidence.get(evidence) : undefined;
1950
+ if (!contract || contract.topologyContract !== topology || seen.has(contract.service.id)
1951
+ || evidence.observedAt > observedAt || !isRuntimeServiceEvidence(evidence)) {
1952
+ return fail('runtime mount evidence is incomplete or unauthenticated');
1953
+ }
1954
+ seen.add(contract.service.id);
1955
+ const expected = topology.runtimeHealth.get(contract.service.id)?.bindMounts;
1956
+ if (!expected || JSON.stringify(expected) !== JSON.stringify(contract.inspection.bindMounts)) {
1957
+ return issueObservation({
1958
+ code: ENVIRONMENT_CODES.MOUNT_MATERIALIZATION_UNAVAILABLE,
1959
+ owner: OWNER,
1960
+ stackId: topology.stackId,
1961
+ source: `${topology.runtime.projectName}:${contract.service.id}`,
1962
+ observed: { state: 'available', value: false, observedAt },
1963
+ expectation: { kind: 'equal', value: true },
1964
+ applicability: { state: 'required', posture: 'running-stack' },
1965
+ freshness: { state: 'current' },
1966
+ remedy: 'restore the exact declared bind mounts and recreate the affected service',
1967
+ });
1968
+ }
1969
+ }
1970
+ if (seen.size !== topology.services.size)
1971
+ return fail('runtime mount evidence is incomplete or unauthenticated');
1972
+ return issueObservation({
1973
+ code: ENVIRONMENT_CODES.MOUNT_MATERIALIZATION_UNAVAILABLE,
1974
+ owner: OWNER,
1975
+ stackId: topology.stackId,
1976
+ source: `${topology.runtime.projectName}:runtime-mounts`,
1977
+ observed: { state: 'available', value: true, observedAt },
1978
+ expectation: { kind: 'equal', value: true },
1979
+ applicability: { state: 'required', posture: 'running-stack' },
1980
+ freshness: { state: 'current' },
1981
+ remedy: 'restore the exact declared bind mounts and recreate the affected service',
1982
+ });
1983
+ }
1984
+ function currentRuntimeServices(topology, values) {
1985
+ if (!Array.isArray(values) || values.length !== topology.services.size)
1986
+ return undefined;
1987
+ const services = new Map();
1988
+ for (const evidence of values) {
1989
+ const contract = evidence && typeof evidence === 'object' ? issuedRuntimeServiceEvidence.get(evidence) : undefined;
1990
+ if (!contract || contract.topologyContract !== topology || services.has(contract.service.id)
1991
+ || !isRuntimeServiceEvidence(evidence))
1992
+ return undefined;
1993
+ services.set(contract.service.id, { evidence, contract });
1994
+ }
1995
+ return services.size === topology.services.size ? services : undefined;
1996
+ }
1997
+ function exactRuntimePorts(expected, actual) {
1998
+ const remaining = new Set(actual.map(({ hostIp, target, published }) => `${hostIp}\0${published}\0${target}`));
1999
+ for (const row of expected) {
2000
+ const identity = `${row.hostIp}\0${row.published}\0${row.target}`;
2001
+ if (row.hostIp !== '0.0.0.0') {
2002
+ if (!remaining.delete(identity))
2003
+ return false;
2004
+ continue;
2005
+ }
2006
+ if (!remaining.delete(identity))
2007
+ return false;
2008
+ remaining.delete(`::\0${row.published}\0${row.target}`);
2009
+ }
2010
+ return remaining.size === 0;
2011
+ }
2012
+ export async function observeRuntimePortFamily(input) {
2013
+ const captured = captureOwnValues(input, ['topology', 'services', 'namespace', 'listenerTimeoutMs', 'revalidate']);
2014
+ const topologyValue = captured?.topology;
2015
+ const topology = topologyValue && typeof topologyValue === 'object' ? issuedTopologyEvidence.get(topologyValue) : undefined;
2016
+ if (!topology)
2017
+ throw new RefusalError('runtime port evidence is not authentic');
2018
+ const now = new Date().toISOString();
2019
+ const unavailable = (reason, observedAt = now) => issueObservation({
2020
+ code: ENVIRONMENT_CODES.PORT_FAMILY_COLLISION,
2021
+ owner: OWNER,
2022
+ stackId: topology.stackId,
2023
+ source: topology.runtime.projectName,
2024
+ observed: { state: 'unavailable', reason, observedAt },
2025
+ expectation: { kind: 'equal', value: true },
2026
+ applicability: { state: 'required', posture: 'running-stack' },
2027
+ freshness: { state: 'current' },
2028
+ remedy: 'restore the retained port lease and the exact declared runtime publication',
2029
+ });
2030
+ try {
2031
+ const services = currentRuntimeServices(topology, captured?.services);
2032
+ const namespace = captured?.namespace;
2033
+ const timeoutMs = captured?.listenerTimeoutMs;
2034
+ const revalidate = captured?.revalidate;
2035
+ const projection = topology.runtime.portLeaseProjection;
2036
+ const lease = projection && namespace ? inspectPortLeaseProjection(projection, namespace) : undefined;
2037
+ if (!services || !namespace || !lease || typeof revalidate !== 'function'
2038
+ || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000
2039
+ || !await revalidatePortLeaseProjection({ projection: lease, namespace })) {
2040
+ return unavailable('runtime port evidence is incomplete or unauthenticated');
2041
+ }
2042
+ const leased = new Set(Object.values(lease.lease.ports));
2043
+ const published = new Set();
2044
+ const listeners = [];
2045
+ const listenerKeys = new Set();
2046
+ for (const [serviceId, current] of services) {
2047
+ const expected = topology.runtimeHealth.get(serviceId)?.publishedPorts;
2048
+ if (!expected || !exactRuntimePorts(expected, current.contract.inspection.publishedPorts)) {
2049
+ return unavailable(`runtime port publication differs from the declared topology for ${serviceId}`);
2050
+ }
2051
+ const rows = current.contract.inspection.publishedPorts;
2052
+ for (const row of rows) {
2053
+ if (!leased.has(row.published))
2054
+ return unavailable(`runtime published port ${row.published} is outside the retained lease`);
2055
+ published.add(row.published);
2056
+ const key = `${row.hostIp}\0${row.published}`;
2057
+ if (listenerKeys.has(key))
2058
+ return unavailable('runtime publication contains a duplicate host address');
2059
+ listenerKeys.add(key);
2060
+ // Compose reports a `::` companion beside every 0.0.0.0 publication, but some
2061
+ // runtimes forward only IPv4 loopback, so requiring ::1 to listen failed healthy
2062
+ // stacks. The IPv4 row still proves the listener; a companion is not required.
2063
+ if (row.hostIp === '::' && rows.some((other) => other.hostIp === '0.0.0.0'
2064
+ && other.published === row.published && other.target === row.target))
2065
+ continue;
2066
+ listeners.push({ hostIp: row.hostIp, port: row.published, expected: 'listening' });
2067
+ }
2068
+ }
2069
+ for (const port of leased) {
2070
+ if (published.has(port))
2071
+ continue;
2072
+ listeners.push({ hostIp: '127.0.0.1', port, expected: 'free' });
2073
+ listeners.push({ hostIp: '::1', port, expected: 'free' });
2074
+ }
2075
+ if (listeners.length === 0)
2076
+ return unavailable('retained port lease contains no observable ports');
2077
+ const observed = await observePortListeners({ bindings: listeners, timeoutMs: timeoutMs });
2078
+ if (observed.kind !== 'complete')
2079
+ return unavailable(observed.reason);
2080
+ const mismatched = observed.rows.filter((row, index) => row.listening !== (listeners[index]?.expected === 'listening'));
2081
+ if (mismatched.length > 0) {
2082
+ const named = mismatched.slice(0, 8).map(({ probeHost, port, listening }) => listening
2083
+ ? `${probeHost === '::1' ? `[${probeHost}]` : probeHost}:${port} has a listener outside the stack`
2084
+ : `${probeHost === '::1' ? `[${probeHost}]` : probeHost}:${port} has no stack listener`);
2085
+ return issueObservation({
2086
+ code: ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, owner: OWNER, stackId: topology.stackId,
2087
+ source: `${topology.runtime.projectName}:runtime-ports`,
2088
+ observed: { state: 'available', value: false, observedAt: observed.observedAt },
2089
+ expectation: { kind: 'equal', value: true }, applicability: { state: 'required', posture: 'running-stack' },
2090
+ freshness: { state: 'current' },
2091
+ remedy: `${named.join('; ')}${mismatched.length > named.length ? `; and ${mismatched.length - named.length} more` : ''}: `
2092
+ + 'stop the process holding a leased port (find it with `lsof -nP -iTCP:<port> -sTCP:LISTEN`), '
2093
+ + 'or restart the stack with `npx --no cockpit up --config <your cockpit.toml>` to restore a missing listener',
2094
+ });
2095
+ }
2096
+ if (!await revalidatePortLeaseProjection({ projection: lease, namespace })) {
2097
+ return unavailable('retained port lease changed during listener observation', observed.observedAt);
2098
+ }
2099
+ const afterValues = await revalidate();
2100
+ const after = currentRuntimeServices(topology, afterValues);
2101
+ if (!after || after.size !== services.size)
2102
+ return unavailable('runtime service identity changed during listener observation', observed.observedAt);
2103
+ for (const [serviceId, before] of services) {
2104
+ const current = after.get(serviceId);
2105
+ if (!current || current.evidence === before.evidence || current.evidence.containerId !== before.evidence.containerId
2106
+ || Date.parse(current.evidence.observedAt) < Date.parse(observed.observedAt)
2107
+ || JSON.stringify(current.contract.inspection.bindMounts) !== JSON.stringify(before.contract.inspection.bindMounts)
2108
+ || JSON.stringify(current.contract.inspection.publishedPorts) !== JSON.stringify(before.contract.inspection.publishedPorts)) {
2109
+ return unavailable('runtime service identity changed during listener observation', observed.observedAt);
2110
+ }
2111
+ }
2112
+ if (!await revalidatePortLeaseProjection({ projection: lease, namespace })) {
2113
+ return unavailable('retained port lease changed during service revalidation', observed.observedAt);
2114
+ }
2115
+ return issueObservation({
2116
+ code: ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, owner: OWNER, stackId: topology.stackId,
2117
+ source: `${topology.runtime.projectName}:runtime-ports`, observed: { state: 'available', value: true, observedAt: observed.observedAt },
2118
+ expectation: { kind: 'equal', value: true }, applicability: { state: 'required', posture: 'running-stack' },
2119
+ freshness: { state: 'current' }, remedy: 'restore the retained port lease and the exact declared runtime publication',
2120
+ });
2121
+ }
2122
+ catch (error) {
2123
+ return unavailable(unavailableReason('runtime port observation failed closed', error));
2124
+ }
2125
+ }
2126
+ export function observeRuntimeServiceColdStart(input) {
2127
+ try {
2128
+ const captured = captureOwnValues(input, ['services']);
2129
+ const services = captured?.services;
2130
+ if (!Array.isArray(services) || services.length < 1 || services.length > MAX_TOPOLOGY_SERVICES) {
2131
+ throw new RefusalError('runtime cold-start evidence is not authentic');
2132
+ }
2133
+ let topology;
2134
+ let startedAt = Number.POSITIVE_INFINITY;
2135
+ let finishedAt = Number.NEGATIVE_INFINITY;
2136
+ let source = '';
2137
+ const observedServices = new Set();
2138
+ for (const evidence of services) {
2139
+ const contract = evidence !== null && typeof evidence === 'object'
2140
+ ? issuedRuntimeServiceEvidence.get(evidence)
2141
+ : undefined;
2142
+ if (!contract || topology && contract.topologyContract !== topology
2143
+ || observedServices.has(evidence.service)
2144
+ || !isRuntimeServiceEvidence(evidence)
2145
+ || !isCanonicalTimestamp(contract.startStartedAt)
2146
+ || !isCanonicalTimestamp(evidence.observedAt)) {
2147
+ throw new RefusalError('runtime cold-start evidence is not authentic');
2148
+ }
2149
+ topology ??= contract.topologyContract;
2150
+ observedServices.add(evidence.service);
2151
+ const serviceStart = Date.parse(contract.startStartedAt);
2152
+ const serviceFinish = Date.parse(evidence.observedAt);
2153
+ if (serviceStart > serviceFinish)
2154
+ throw new RefusalError('runtime cold-start evidence is not authentic');
2155
+ if (serviceStart < startedAt) {
2156
+ startedAt = serviceStart;
2157
+ source = evidence.service;
2158
+ }
2159
+ finishedAt = Math.max(finishedAt, serviceFinish);
2160
+ }
2161
+ if (!topology)
2162
+ throw new RefusalError('runtime cold-start evidence is not authentic');
2163
+ const duration = finishedAt - startedAt;
2164
+ const observedAt = new Date(finishedAt).toISOString();
2165
+ return issueObservation({
2166
+ code: ENVIRONMENT_CODES.MACHINE_COLD_START_EXCEEDED,
2167
+ owner: OWNER,
2168
+ stackId: topology.stackId,
2169
+ source,
2170
+ observed: Number.isSafeInteger(duration) && duration >= 0
2171
+ ? { state: 'available', value: duration, observedAt }
2172
+ : { state: 'failed', reason: 'runtime receipt timestamps are incoherent', observedAt },
2173
+ expectation: { kind: 'maximum', value: topology.runtime.coldStartLimitMillis },
2174
+ applicability: { state: 'required', posture: 'environment' },
2175
+ freshness: { state: 'current' },
2176
+ remedy: 'restore the declared cold-start budget',
2177
+ });
2178
+ }
2179
+ catch (error) {
2180
+ if (error instanceof RefusalError && error.message === 'runtime cold-start evidence is not authentic')
2181
+ throw error;
2182
+ throw new RefusalError('runtime cold-start evidence is not authentic');
2183
+ }
2184
+ }
2185
+ function runtimeServiceFailureContext(contract, stage, observedAt, containerId) {
2186
+ const topology = contract.topologyContract;
2187
+ const step = snapshotExecutionPlan({ steps: [topology.step] })?.steps[0];
2188
+ const configIndex = step?.argv.lastIndexOf('config') ?? -1;
2189
+ const suffix = step?.argv.slice(configIndex);
2190
+ if (!step || configIndex < 2 || step.argv[1] !== 'compose'
2191
+ || !(suffix?.length === 4 && suffix[1] === '--format' && suffix[2] === 'json'
2192
+ && suffix[3] === '--no-env-resolution'))
2193
+ return undefined;
2194
+ return {
2195
+ stage,
2196
+ stackId: topology.stackId,
2197
+ configSha256: topology.configSha256,
2198
+ batchId: topology.batchId,
2199
+ runtimeVersion: topology.runtime.runtimeVersion,
2200
+ composeVersion: topology.runtime.composeVersion,
2201
+ executableSha256: topology.runtime.runtimeExecutable.sha256,
2202
+ projectName: topology.runtime.projectName,
2203
+ service: contract.service.id,
2204
+ containerId,
2205
+ observedAt,
2206
+ observationWindow: topology.observationWindow,
2207
+ executable: { ...topology.runtime.runtimeExecutable },
2208
+ cwd: topology.runtime.cwd,
2209
+ env: runtimeComposeEnvironment(topology.runtime.env, topology.runtime.interpolatedNames),
2210
+ composeArgvPrefix: step.argv.slice(1, configIndex),
2211
+ paths: step.paths,
2212
+ consumedFiles: step.consumedFiles,
2213
+ filesystemChecks: step.filesystemChecks,
2214
+ };
2215
+ }
2216
+ function failedBoundaryObservedAt(outcome, window, previousFinishedAt) {
2217
+ const now = Date.now();
2218
+ const windowStart = Date.parse(window.startedAt);
2219
+ const windowEnd = Date.parse(window.expiresAt);
2220
+ if (now < windowStart || now > windowEnd)
2221
+ return undefined;
2222
+ if (!outcome.receipt)
2223
+ return outcome.kind === 'refusal' && !outcome.execution.started && outcome.execution.signal === null
2224
+ && (!previousFinishedAt || Date.parse(previousFinishedAt) <= now)
2225
+ ? new Date(now).toISOString()
2226
+ : undefined;
2227
+ const startedAt = outcome.receipt.startedAt;
2228
+ const finishedAt = outcome.receipt.finishedAt;
2229
+ if (!isCanonicalTimestamp(startedAt) || !isCanonicalTimestamp(finishedAt)
2230
+ || Date.parse(startedAt) < windowStart || Date.parse(startedAt) > Date.parse(finishedAt)
2231
+ || Date.parse(finishedAt) > now || Date.parse(finishedAt) > windowEnd
2232
+ || previousFinishedAt && Date.parse(previousFinishedAt) > Date.parse(startedAt))
2233
+ return undefined;
2234
+ return finishedAt;
2235
+ }
2236
+ export function consumeRuntimeServiceFailureForLogs(evidence) {
2237
+ if (!evidence || typeof evidence !== 'object')
2238
+ return undefined;
2239
+ const context = issuedRuntimeServiceFailures.get(evidence);
2240
+ if (!context)
2241
+ return undefined;
2242
+ issuedRuntimeServiceFailures.delete(evidence);
2243
+ return context;
2244
+ }
2245
+ export function consumeRuntimeServiceObservationForFailureLogs(value) {
2246
+ try {
2247
+ const contract = value !== null && typeof value === 'object' ? issuedRuntimeServiceObservations.get(value) : undefined;
2248
+ const now = Date.now();
2249
+ if (!contract || consumedRuntimeServiceFailureObservations.has(value)
2250
+ || now < Date.parse(contract.topologyContract.observationWindow.startedAt)
2251
+ || now > Date.parse(contract.topologyContract.observationWindow.expiresAt))
2252
+ return undefined;
2253
+ const env = runtimeCommandEnvironment(contract.topologyContract.runtime.env, contract.topologyContract.runtime.interpolatedNames);
2254
+ const context = {
2255
+ observation: value,
2256
+ executable: { ...contract.topologyContract.runtime.runtimeExecutable },
2257
+ cwd: contract.topologyContract.runtime.cwd,
2258
+ env,
2259
+ };
2260
+ consumedRuntimeServiceFailureObservations.add(value);
2261
+ return context;
2262
+ }
2263
+ catch {
2264
+ // A throwing accessor or proxy trap makes the failure observation unusable, which the caller refuses.
2265
+ return undefined;
2266
+ }
2267
+ }
2268
+ function unavailableRuntimeStop(error) {
2269
+ return { kind: 'unavailable', reason: unavailableReason('runtime stop evidence is unavailable', error) };
2270
+ }
2271
+ function receiptWithin(outcome, notBefore, notAfter) {
2272
+ const receipt = outcome.receipt;
2273
+ return receipt !== undefined && isCanonicalTimestamp(receipt.startedAt) && isCanonicalTimestamp(receipt.finishedAt)
2274
+ && Date.parse(receipt.startedAt) >= Date.parse(notBefore)
2275
+ && Date.parse(receipt.startedAt) <= Date.parse(receipt.finishedAt)
2276
+ && Date.parse(receipt.finishedAt) <= Date.parse(notAfter)
2277
+ && Date.parse(receipt.finishedAt) <= Date.now();
2278
+ }
2279
+ /**
2280
+ * Runs the drain's runtime-stop action: `compose stop` under the attempt's execution fence, then a
2281
+ * fresh `ps` that must find nothing running, and settles the attempt as the outcome to publish.
2282
+ */
2283
+ export async function stopRuntime(input) {
2284
+ const topology = issuedTopologyEvidence.get(input.topology);
2285
+ const { reservation, attempt, fence } = input.attempt;
2286
+ const now = Date.now();
2287
+ if (!topology || attempt.status !== 'active' || reservation.attempt?.attemptId !== attempt.attemptId
2288
+ || reservation.stackId !== topology.stackId
2289
+ || reservation.configSha256 !== topology.configSha256
2290
+ || attempt.currentStage !== 'runtime-stop' || attempt.currentTarget !== reservation.stackId
2291
+ || now < Date.parse(topology.observationWindow.startedAt)
2292
+ || now > Date.parse(topology.observationWindow.expiresAt)
2293
+ || now < Date.parse(attempt.committedAt) || now > Date.parse(attempt.validThrough))
2294
+ return unavailableRuntimeStop();
2295
+ const step = derivedComposeStep({ ...topology.step, timeoutMs: topology.step.timeoutMs + COMPOSE_STOP_GRACE_MS * dependencyLevels(topology.services) }, `runtime:docker-compose:stop:${topology.runtime.projectName}`, 'stop', ['--progress', 'quiet', 'stop'], OUTPUT_LIMIT_BYTES, reservation.generation, Date.parse(attempt.validThrough) - now, attempt.validThrough, topology.runtime.interpolatedNames);
2296
+ if (!step)
2297
+ return unavailableRuntimeStop();
2298
+ const stopped = await input.execute({ ...step, executionFence: fence });
2299
+ if (stopped.kind === 'refusal') {
2300
+ // Only a stop that provably never started leaves the stack as it was.
2301
+ if (stopped.receipt !== undefined || stopped.execution.started || stopped.execution.signal !== null
2302
+ || stopped.execution.exitCode === 0)
2303
+ return unavailableRuntimeStop();
2304
+ return { kind: 'not-started', code: stopped.code, outcome: { kind: 'failure', reservation, failedAt: new Date().toISOString() }, fence };
2305
+ }
2306
+ if (stopped.stderr.byteLength !== 0 || !receiptWithin(stopped, attempt.committedAt, attempt.validThrough))
2307
+ return unavailableRuntimeStop();
2308
+ const observationStep = derivedComposeStep(topology.step, `runtime:docker-compose:stopped:${topology.runtime.projectName}`, 'ps-stopped', ['ps', '--format', 'json', '--status', 'running'], OUTPUT_LIMIT_BYTES, reservation.generation, MAX_OBSERVATION_WINDOW_MS, undefined, topology.runtime.interpolatedNames);
2309
+ if (!observationStep)
2310
+ return unavailableRuntimeStop();
2311
+ const observed = await input.execute(observationStep);
2312
+ if (observed.kind !== 'complete' || observed.stderr.byteLength !== 0 || observed.stdout.byteLength !== 0
2313
+ || !receiptWithin(observed, stopped.receipt.finishedAt, attempt.validThrough))
2314
+ return unavailableRuntimeStop();
2315
+ const fact = {
2316
+ purpose: reservation.purpose,
2317
+ stackId: reservation.stackId,
2318
+ configSha256: reservation.configSha256,
2319
+ planDigest: reservation.planDigest,
2320
+ operationBasis: reservation.operationBasis,
2321
+ coordinationRevision: reservation.coordinationRevision,
2322
+ reservationId: reservation.reservationId,
2323
+ reservedAt: reservation.reservedAt,
2324
+ generation: reservation.generation,
2325
+ attemptId: attempt.attemptId,
2326
+ attemptRevision: attempt.committedRevision,
2327
+ attemptCommittedAt: attempt.committedAt,
2328
+ validThrough: attempt.validThrough,
2329
+ actionIndex: attempt.actionIndex,
2330
+ currentTarget: attempt.currentTarget,
2331
+ currentStage: attempt.currentStage,
2332
+ observedAt: observed.receipt.finishedAt,
2333
+ };
2334
+ return { kind: 'stopped', outcome: { kind: 'success', reservation, fact }, fence };
2335
+ }