@b00dea/cockpit 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -1
  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 +76 -1
  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,1835 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { lstatSync } from 'node:fs';
4
+ import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
5
+ import { admitLocalDockerEndpoint } from './docker-endpoint.js';
6
+ import { decodeCoordinationRecord, inspectFailedReclaimRecovery, matchReclaimDrainContinuation, reclaimDrainStep, } from './claims.js';
7
+ import { hasErrorCode, tryParseJson, RefusalError, ENVIRONMENT_CODES, errorCause, filesystemObjectIdentity, isCanonicalTimestamp, isFilesystemObjectIdentity, sameFilesystemObject, snapshotExecutionStep, snapshotJson, } from './contract.js';
8
+ import { resolvedDockerExecutable, resolvedExecutable } from './executable.js';
9
+ import { readState } from './state.js';
10
+ import { exactKeys } from '../exact-keys.js';
11
+ const STACK_ID = /^stack:[a-f0-9]{64}$/;
12
+ const COCKPIT_ID = /^cockpit:[a-z0-9][a-z0-9-]*:[a-f0-9]{64}$/;
13
+ const SHA256 = /^[a-f0-9]{64}$/;
14
+ const BUILD_CACHE_ID = /^[a-z0-9]{25}$/;
15
+ const RESOURCE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@+-]{0,255}$/;
16
+ const DOCKER_SELECTOR = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
17
+ const REQUIRED_LABELS = Object.freeze({
18
+ stack: 'dev.b0dea.cockpit.stack-id',
19
+ config: 'dev.b0dea.cockpit.config-sha256',
20
+ owner: 'dev.b0dea.cockpit.owner-id',
21
+ });
22
+ // Compose labels every volume it creates with its project and the volume's key in the Compose
23
+ // file. `resettable_volumes` names that key, and Docker's volume name differs per checkout.
24
+ const COMPOSE_LABELS = Object.freeze({
25
+ project: 'com.docker.compose.project',
26
+ volume: 'com.docker.compose.volume',
27
+ });
28
+ const COMPOSE_PROJECT = /^[a-z0-9][a-z0-9_-]*$/;
29
+ const MAX_RESOURCES = 4_096;
30
+ const OUTPUT_LIMIT = 1_048_576;
31
+ // Inspections are chunked so a stack with many resources still needs few Docker clients. The chunk
32
+ // is sized from the per-call output cap: every printed object is held to a fixed budget, and a
33
+ // chunk's cap is its object count times that budget.
34
+ const INSPECTION_OBJECT_BUDGET = 16_384;
35
+ const INSPECTION_CHUNK = Math.min(64, Math.floor(OUTPUT_LIMIT / INSPECTION_OBJECT_BUDGET));
36
+ // [executable, --host, endpoint, <type>, inspect, --format, <format>, ...ids]
37
+ const INSPECTION_ARGV_PREFIX = 7;
38
+ const DAEMON_ENV = /^(?:DOCKER|BUILDX|CONTAINER|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY)(?:_|$)/i;
39
+ const SAFE_ENV = new Set(['PATH', 'TMPDIR', 'LANG', 'LC_ALL']);
40
+ // A discovery may legitimately contain thousands of resources. Each inspection launches a Docker
41
+ // client, so one shared bound covers every resource kind rather than letting each kind
42
+ // independently saturate the daemon.
43
+ const MAX_CONCURRENT_INSPECTIONS = 4;
44
+ const DISCOVERY_KINDS = ['container', 'network', 'volume', 'image', 'build-cache'];
45
+ function diagnostic(code, message, target, fix) {
46
+ return { severity: 'error', code, message, target, ...(fix ? { fix } : {}) };
47
+ }
48
+ function refuse(code, message, target, fix) {
49
+ return { kind: 'refusal', diagnostics: [diagnostic(code, message, target, fix)] };
50
+ }
51
+ function lexical(left, right) {
52
+ return left < right ? -1 : left > right ? 1 : 0;
53
+ }
54
+ function snapshotResource(value) {
55
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
56
+ return undefined;
57
+ const candidate = value;
58
+ const keys = [
59
+ 'id', 'name', 'kind', 'labels', 'referenced', 'bytes',
60
+ ...(candidate.dataBearing === undefined ? [] : ['dataBearing']),
61
+ ...(candidate.generation === undefined ? [] : ['generation']),
62
+ ];
63
+ if (!exactKeys(value, keys)
64
+ || typeof candidate.id !== 'string' || !RESOURCE_ID.test(candidate.id)
65
+ || typeof candidate.name !== 'string' || candidate.name.length === 0 || candidate.name.length > 512 || candidate.name.includes('\0')
66
+ || !['container', 'network', 'volume', 'image', 'build-cache', 'cache'].includes(candidate.kind)
67
+ || candidate.labels === null || typeof candidate.labels !== 'object' || Array.isArray(candidate.labels)
68
+ || Object.keys(candidate.labels).length > 64
69
+ || Object.entries(candidate.labels).some(([key, item]) => key.length === 0 || key.length > 256 || key.includes('\0')
70
+ || typeof item !== 'string' || item.length > 512 || item.includes('\0'))
71
+ || typeof candidate.referenced !== 'boolean'
72
+ || candidate.bytes !== null && (!Number.isSafeInteger(candidate.bytes) || candidate.bytes < 0)
73
+ || candidate.dataBearing !== undefined && typeof candidate.dataBearing !== 'boolean'
74
+ || candidate.generation !== undefined && (candidate.kind !== 'volume' || typeof candidate.generation !== 'string'
75
+ || candidate.generation.length === 0 || candidate.generation.length > 128 || candidate.generation.includes('\0')))
76
+ return undefined;
77
+ return {
78
+ id: candidate.id,
79
+ name: candidate.name,
80
+ kind: candidate.kind,
81
+ labels: { ...candidate.labels },
82
+ referenced: candidate.referenced,
83
+ bytes: candidate.bytes,
84
+ ...(candidate.dataBearing === undefined ? {} : { dataBearing: candidate.dataBearing }),
85
+ ...(candidate.generation === undefined ? {} : { generation: candidate.generation }),
86
+ };
87
+ }
88
+ function snapshotClassificationInput(value) {
89
+ const snapshot = snapshotJson(value);
90
+ if (snapshot === undefined || snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)
91
+ || !exactKeys(snapshot, ['stackId', 'cockpitId', 'configSha256', 'coordinationRecord', 'declaredCacheIds', 'discovery']))
92
+ return undefined;
93
+ const input = snapshot;
94
+ if (typeof input.stackId !== 'string' || !STACK_ID.test(input.stackId)
95
+ || typeof input.cockpitId !== 'string' || !COCKPIT_ID.test(input.cockpitId)
96
+ || typeof input.configSha256 !== 'string' || !SHA256.test(input.configSha256)
97
+ || !Array.isArray(input.declaredCacheIds) || input.declaredCacheIds.length > MAX_RESOURCES
98
+ || input.declaredCacheIds.some((id) => typeof id !== 'string' || !(BUILD_CACHE_ID.test(id) || SHA256.test(id)))
99
+ || new Set(input.declaredCacheIds).size !== input.declaredCacheIds.length
100
+ || input.discovery === null || typeof input.discovery !== 'object' || Array.isArray(input.discovery))
101
+ return undefined;
102
+ const discovery = input.discovery;
103
+ if (typeof discovery.observedAt !== 'string' || !isCanonicalTimestamp(discovery.observedAt)
104
+ || discovery.producer !== 'runtime-resource-discovery'
105
+ || !Array.isArray(discovery.resources) || discovery.resources.length > MAX_RESOURCES)
106
+ return undefined;
107
+ const resources = discovery.resources.map(snapshotResource);
108
+ if (resources.some((item) => item === undefined))
109
+ return undefined;
110
+ const ids = resources.map((item) => item.id);
111
+ if (new Set(ids).size !== ids.length)
112
+ return undefined;
113
+ const coordinationRecord = input.coordinationRecord;
114
+ if (coordinationRecord !== null) {
115
+ if (typeof coordinationRecord !== 'object' || Array.isArray(coordinationRecord))
116
+ return undefined;
117
+ const record = coordinationRecord;
118
+ if (!exactKeys(record, ['format', 'kind', 'cockpitId', 'subjectId', 'revision', 'updatedAt', 'value'])
119
+ || record.format !== 'cockpit-state/v1' || record.kind !== 'coordination'
120
+ || record.cockpitId !== input.cockpitId || record.subjectId !== input.stackId
121
+ || !Number.isSafeInteger(record.revision) || record.revision < 0
122
+ || typeof record.updatedAt !== 'string' || !isCanonicalTimestamp(record.updatedAt))
123
+ return undefined;
124
+ }
125
+ return {
126
+ stackId: input.stackId,
127
+ cockpitId: input.cockpitId,
128
+ configSha256: input.configSha256,
129
+ coordinationRecord: coordinationRecord,
130
+ declaredCacheIds: [...input.declaredCacheIds],
131
+ discovery: {
132
+ observedAt: discovery.observedAt,
133
+ producer: 'runtime-resource-discovery',
134
+ resources: resources,
135
+ },
136
+ };
137
+ }
138
+ function labelOwnership(resource, input, positiveState) {
139
+ const present = Object.values(REQUIRED_LABELS).filter((name) => Object.hasOwn(resource.labels, name));
140
+ const exactCache = input.declaredCacheIds.includes(resource.id) && resource.name === resource.id
141
+ && (resource.kind === 'build-cache' && BUILD_CACHE_ID.test(resource.id) || resource.kind === 'cache' && SHA256.test(resource.id));
142
+ if (present.length === 0) {
143
+ return exactCache
144
+ ? { ownership: 'unknown', evidence: ['exact declared cache identity; ownership labels absent'], reason: 'the exact declared cache has no current ownership evidence' }
145
+ : { ownership: 'unlabelled', evidence: ['all required ownership labels absent'], reason: 'the resource carries no ownership labels' };
146
+ }
147
+ if (present.length !== Object.keys(REQUIRED_LABELS).length) {
148
+ return { ownership: 'partial', evidence: [`${present.length} of 3 required ownership labels present`], reason: 'partial labels cannot establish ownership' };
149
+ }
150
+ const stack = resource.labels[REQUIRED_LABELS.stack];
151
+ const config = resource.labels[REQUIRED_LABELS.config];
152
+ const owner = resource.labels[REQUIRED_LABELS.owner];
153
+ if (!STACK_ID.test(stack) || !SHA256.test(config) || !COCKPIT_ID.test(owner)) {
154
+ return { ownership: 'malformed', evidence: ['required label values failed their closed grammar'], reason: 'malformed labels cannot establish ownership' };
155
+ }
156
+ if (stack !== input.stackId || owner !== input.cockpitId) {
157
+ return { ownership: 'foreign', evidence: [`stack=${stack}`, `owner=${owner}`], reason: 'the labels identify another stack or cockpit' };
158
+ }
159
+ if (!positiveState) {
160
+ return { ownership: 'unknown', evidence: ['exact labels matched; coordinated state evidence absent'], reason: 'labels alone do not grant ownership' };
161
+ }
162
+ if (config !== input.configSha256) {
163
+ return { ownership: 'stale-config', evidence: [`config=${config}`, `current=${input.configSha256}`], reason: 'this stack started the resource under an earlier configuration' };
164
+ }
165
+ return { ownership: 'current', evidence: ['exact stack, owner, and configuration labels matched coordinated state'], reason: 'positive labels and state identify the current stack' };
166
+ }
167
+ function reversibility(resource, declaredCacheIds) {
168
+ if (declaredCacheIds.includes(resource.id) && resource.name === resource.id
169
+ && (resource.kind === 'build-cache' && BUILD_CACHE_ID.test(resource.id) || resource.kind === 'cache' && SHA256.test(resource.id)))
170
+ return 'regenerable-cache';
171
+ if (resource.dataBearing === true || resource.kind === 'volume')
172
+ return 'data-bearing';
173
+ return 'runtime';
174
+ }
175
+ function shellQuote(value) {
176
+ return `'${value.replaceAll("'", "'\\''")}'`;
177
+ }
178
+ function suggestion(resource, ownership) {
179
+ if (resource.kind === 'build-cache' || resource.kind === 'cache')
180
+ return undefined;
181
+ const noun = resource.kind === 'image' ? 'image' : resource.kind;
182
+ return {
183
+ command: `docker ${noun} rm -- ${shellQuote(resource.id)}`,
184
+ risk: `${ownership} ${resource.kind} is not owned for automatic removal; verify its consumers before running this exact command`,
185
+ };
186
+ }
187
+ export function classifyReclaim(rawInput) {
188
+ const input = snapshotClassificationInput(rawInput);
189
+ if (input === undefined)
190
+ return refuse(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reclaim discovery input is malformed or exceeds its bounds', 'reclaim-discovery');
191
+ const decoded = input.coordinationRecord === null ? undefined : decodeCoordinationRecord(input.coordinationRecord, input.stackId);
192
+ if (input.coordinationRecord !== null && (decoded === undefined || input.coordinationRecord.cockpitId !== input.cockpitId)) {
193
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination evidence does not match the requested stack and cockpit', input.stackId);
194
+ }
195
+ const positiveState = decoded !== undefined;
196
+ const resources = input.discovery.resources.map((resource) => {
197
+ const evidence = labelOwnership(resource, input, positiveState);
198
+ const reverse = reversibility(resource, input.declaredCacheIds);
199
+ const exactUnknownCache = (resource.kind === 'build-cache' || resource.kind === 'cache') && evidence.ownership === 'unknown'
200
+ && reverse === 'regenerable-cache' && !resource.referenced;
201
+ // The stack and owner labels are the stack's stable identity; the configuration label only
202
+ // records which cockpit.toml started the resource. Keeping a container or network an earlier
203
+ // configuration started made `down` after an edited cockpit.toml report the stack removed
204
+ // while it stayed. Its data stays out of teardown's reach: only a declared `reset` removes it.
205
+ const action = resource.referenced ? 'report-only' : evidence.ownership === 'current'
206
+ ? reverse === 'data-bearing' ? 'reset-required'
207
+ : reverse === 'regenerable-cache' ? 'reclaim' : 'teardown'
208
+ : evidence.ownership === 'stale-config' && reverse === 'runtime' ? 'teardown'
209
+ : exactUnknownCache ? 'reclaim' : 'report-only';
210
+ return {
211
+ ...resource,
212
+ ownership: evidence.ownership,
213
+ reversibility: reverse,
214
+ action,
215
+ evidence: [...evidence.evidence],
216
+ reason: evidence.reason,
217
+ ...(action === 'report-only' ? { suggestion: suggestion(resource, evidence.ownership) } : {}),
218
+ };
219
+ }).sort((left, right) => lexical(left.id, right.id));
220
+ return { kind: 'complete', noop: resources.length === 0, resources };
221
+ }
222
+ /**
223
+ * The resources a finished `down` leaves behind although this stack's labels and coordinated state
224
+ * show they are its own, each with the reason teardown may not remove it. Volumes are left out:
225
+ * `down` keeps data by design, and only `reset` deletes it.
226
+ */
227
+ export function keptTeardownResources(input) {
228
+ const classified = classifyReclaim(reclaimInput(input));
229
+ if (classified.kind !== 'complete')
230
+ return undefined;
231
+ return classified.resources
232
+ .filter(({ kind, ownership, action }) => kind !== 'volume' && action === 'report-only'
233
+ && (ownership === 'current' || ownership === 'stale-config'))
234
+ .map(({ kind, id, name, referenced, reason }) => ({
235
+ kind, id, name,
236
+ reason: !referenced ? reason
237
+ : kind === 'network' ? 'a container outside this stack is still attached to it'
238
+ : kind === 'image' ? 'it is tagged under more than one name, so another name may still need it'
239
+ : 'something outside this stack still uses it',
240
+ }));
241
+ }
242
+ function safeEnvironment(value) {
243
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
244
+ return undefined;
245
+ const entries = Object.entries(value);
246
+ if (entries.some(([key, item]) => !key || key.includes('\0') || key.includes('=') || DAEMON_ENV.test(key)
247
+ || typeof item !== 'string' || item.includes('\0')))
248
+ return undefined;
249
+ return Object.fromEntries(entries.filter(([key]) => SAFE_ENV.has(key)).sort(([a], [b]) => lexical(a, b)));
250
+ }
251
+ const contextSelectionFormat = '{"context":{{json .Name}},"endpoint":{{json .Endpoints.docker.Host}}}';
252
+ function selectionRow(bytes) {
253
+ const text = bytes.toString('utf8');
254
+ if (!Buffer.from(text, 'utf8').equals(bytes))
255
+ return undefined;
256
+ try {
257
+ const parsed = JSON.parse(text.trim());
258
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
259
+ }
260
+ catch {
261
+ // Unparseable docker output is unproven; the caller refuses the discovery.
262
+ return undefined;
263
+ }
264
+ }
265
+ // Ownership is label-based: a resource without this stack's label is never removed, so listing the
266
+ // rest of a shared daemon only cost time. Listing and inspecting every container, image and volume
267
+ // on a daemon with a hundred unrelated containers made each `down` observation take seconds, and
268
+ // `container ls --size` alone ran past the step timeout. Volumes stay unfiltered because a reset
269
+ // refuses when a declared volume name belongs to something this stack does not own, and an
270
+ // unlabelled volume of that name would otherwise be invisible; their inspection is still scoped.
271
+ function runtimeCommand(kind, selection, stackId) {
272
+ if (kind === 'build-cache')
273
+ return ['--host', selection.endpoint, 'builder', 'du', '--format', 'json'];
274
+ const prefix = ['--host', selection.endpoint];
275
+ const owned = ['--filter', `label=${REQUIRED_LABELS.stack}=${stackId}`];
276
+ const command = kind === 'container' ? ['container', 'ls', '--all', '--no-trunc', ...owned]
277
+ : kind === 'volume' ? ['volume', 'ls'] : kind === 'image' ? ['image', 'ls', '--all', '--no-trunc', ...owned] : [kind, 'ls', '--no-trunc', ...owned];
278
+ return [...prefix, ...command, '--format', discoveryFormat(kind)];
279
+ }
280
+ function discoveryFormat(kind) {
281
+ const id = kind === 'volume' ? '{{json .Name}}' : '{{json .ID}}';
282
+ const name = kind === 'container' ? '{{json .Names}}' : kind === 'image' ? '{{json .Repository}}' : '{{json .Name}}';
283
+ const label = (key) => `{{json (.Label "${key}")}}`;
284
+ // Without `--size` a container prints a misleading "0B (virtual ...)", so its size is unmeasured.
285
+ const bytes = kind === 'network' || kind === 'container' ? 'null' : '{{json .Size}}';
286
+ const labels = kind === 'image' ? '{}' : `{${listedLabels(kind).map((key) => `"${key}":${label(key)}`).join(',')}}`;
287
+ return `{"id":${id},"name":${name},"labels":${labels},"referenced":false,"bytes":${bytes}}`;
288
+ }
289
+ function listedLabels(kind) {
290
+ return [...Object.values(REQUIRED_LABELS), ...kind === 'volume' ? Object.values(COMPOSE_LABELS) : []];
291
+ }
292
+ function dockerLabels(value, kind) {
293
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
294
+ return undefined;
295
+ const labels = {};
296
+ const allowed = new Set(listedLabels(kind));
297
+ if (Object.keys(value).some((key) => !allowed.has(key)))
298
+ return undefined;
299
+ for (const [key, labelValue] of Object.entries(value)) {
300
+ if (typeof labelValue !== 'string' || labelValue.length > 512 || labelValue.includes('\0'))
301
+ return undefined;
302
+ if (labelValue !== '')
303
+ labels[key] = labelValue;
304
+ }
305
+ return labels;
306
+ }
307
+ function dockerBytes(value, allowVirtualSize = false) {
308
+ if (value === undefined || value === null || value === '' || value === 'N/A')
309
+ return null;
310
+ if (Number.isSafeInteger(value) && value >= 0)
311
+ return value;
312
+ if (typeof value !== 'string')
313
+ return undefined;
314
+ if (/^\d+$/.test(value)) {
315
+ const bytes = Number(value);
316
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
317
+ }
318
+ const match = /^(\d+(?:\.\d+)?)\s*(B|kB|MB|GB|TB|KiB|MiB|GiB|TiB)(?: \(virtual \d+(?:\.\d+)?\s*(?:B|kB|MB|GB|TB|KiB|MiB|GiB|TiB)\))?$/.exec(value);
319
+ if (!allowVirtualSize && value.includes('(virtual '))
320
+ return undefined;
321
+ if (!match)
322
+ return undefined;
323
+ const powers = { B: 1, kB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3, TiB: 1024 ** 4 };
324
+ // Docker prints rounded decimal sizes ("1.029GB"), and scaling the decimal is inexact in floating
325
+ // point (1028999999.9999999), so round to whole bytes; the printed value is already approximate.
326
+ const bytes = Math.round(Number(match[1]) * powers[match[2]]);
327
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
328
+ }
329
+ function dockerResource(value, kind) {
330
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
331
+ return undefined;
332
+ const row = value;
333
+ if (kind === 'build-cache') {
334
+ const id = row.ID;
335
+ const reclaimable = row.Reclaimable;
336
+ const bytes = dockerBytes(row.Size);
337
+ return typeof id === 'string' && (reclaimable === true || reclaimable === false || reclaimable === 'true' || reclaimable === 'false')
338
+ && bytes !== undefined
339
+ ? snapshotResource({ id, name: id, kind, labels: {}, referenced: reclaimable === false || reclaimable === 'false', bytes })
340
+ : undefined;
341
+ }
342
+ if (!exactKeys(row, ['id', 'name', 'labels', 'referenced', 'bytes']))
343
+ return undefined;
344
+ const id = row.id;
345
+ const name = row.name;
346
+ const labels = kind === 'image'
347
+ ? row.labels !== null && typeof row.labels === 'object' && !Array.isArray(row.labels) && exactKeys(row.labels, []) ? {} : undefined
348
+ : dockerLabels(row.labels, kind);
349
+ const bytes = dockerBytes(row.bytes, kind === 'container');
350
+ const referenced = kind === 'image' ? typeof row.referenced === 'boolean' ? row.referenced : undefined : row.referenced;
351
+ return typeof id === 'string' && typeof name === 'string' && labels !== undefined && bytes !== undefined && typeof referenced === 'boolean'
352
+ ? snapshotResource({
353
+ id, name, kind, labels, referenced, bytes,
354
+ ...(kind === 'volume' ? { dataBearing: true } : {}),
355
+ })
356
+ : undefined;
357
+ }
358
+ function inspectionSteps(authority, type, format, ids, labels) {
359
+ const steps = [];
360
+ for (let start = 0; start < ids.length; start += INSPECTION_CHUNK) {
361
+ const chunk = ids.slice(start, start + INSPECTION_CHUNK);
362
+ steps.push(exactStep(`runtime:inspect:${labels.resourceKind}:${digestValue(chunk).slice(0, 16)}`, [authority.executable.canonicalPath, '--host', authority.selection.endpoint, type, 'inspect', '--format', format, ...chunk], authority.executable, authority.cwd, authority.env, 30_000, [], { ...labels, resourceCount: String(chunk.length) }, [], chunk.length * INSPECTION_OBJECT_BUDGET));
363
+ }
364
+ return steps;
365
+ }
366
+ function inspectedIds(step) {
367
+ return step.argv.slice(INSPECTION_ARGV_PREFIX);
368
+ }
369
+ /** One parsed row per inspected id, in argument order; any missing, extra or oversized row fails the chunk. */
370
+ function inspectionRows(stdout, ids) {
371
+ const text = stdout.toString('utf8');
372
+ if (!Buffer.from(text, 'utf8').equals(stdout))
373
+ return undefined;
374
+ const lines = text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n');
375
+ if (lines.length !== ids.length)
376
+ return undefined;
377
+ const rows = [];
378
+ for (const line of lines) {
379
+ if (Buffer.byteLength(line) > INSPECTION_OBJECT_BUDGET)
380
+ return undefined;
381
+ // Unparseable docker output is unproven; the caller refuses the discovery.
382
+ const row = tryParseJson(line);
383
+ if (row === null || typeof row !== 'object' || Array.isArray(row))
384
+ return undefined;
385
+ rows.push(row);
386
+ }
387
+ return rows;
388
+ }
389
+ /**
390
+ * Name an inspection chunk that the per-object budget cannot hold, so the operator learns which
391
+ * resources to look at instead of a generic unobservable precondition.
392
+ */
393
+ export function inspectionOverflow(step, outcome) {
394
+ if (!step.id.startsWith('runtime:inspect:'))
395
+ return undefined;
396
+ const ids = inspectedIds(step);
397
+ const oversized = outcome.kind === 'refusal' && outcome.code === ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT
398
+ || outcome.kind === 'complete' && outcome.stdout.toString('utf8').split('\n').some((line) => Buffer.byteLength(line) > INSPECTION_OBJECT_BUDGET);
399
+ if (!oversized)
400
+ return undefined;
401
+ return `docker ${step.argv[3]} inspect printed more than ${INSPECTION_OBJECT_BUDGET} bytes for one of ${ids.length} resources `
402
+ + `(${ids.slice(0, 4).join(', ')}${ids.length > 4 ? ', ...' : ''}); inspect them and remove or relabel the oversized one`;
403
+ }
404
+ function imageInspectionFormat() {
405
+ const label = (key) => `{{json (or (index . "${key}") "")}}`;
406
+ return `{"id":{{json .Id}},"labels":{{with (index .Config "Labels")}}{"${REQUIRED_LABELS.stack}":${label(REQUIRED_LABELS.stack)},"${REQUIRED_LABELS.config}":${label(REQUIRED_LABELS.config)},"${REQUIRED_LABELS.owner}":${label(REQUIRED_LABELS.owner)}}{{else}}{}{{end}}}`;
407
+ }
408
+ // Members are the attached endpoint ids, so a network can be told apart from the containers that
409
+ // hold it; container ids are hex, so the space-separated list is a safe JSON string.
410
+ function networkInspectionFormat() {
411
+ return '{"id":{{json .Id}},"members":"{{range $member, $endpoint := .Containers}}{{$member}} {{end}}"}';
412
+ }
413
+ // A container that carries the network's exact ownership triple belongs to the same stack, so the
414
+ // teardown that may remove the network removes that container first (containers sort before
415
+ // networks). Counting such containers as references left every stack's own network behind after
416
+ // `down`. Any other holder, or one discovery did not see, still keeps the network.
417
+ function sameStackHolder(network, container) {
418
+ return container !== undefined && Object.values(REQUIRED_LABELS).every((name) => typeof network.labels[name] === 'string' && network.labels[name] === container.labels[name]);
419
+ }
420
+ // Discovery lists only this stack's containers, so a foreign container that holds one of its
421
+ // networks is found by asking Docker for every container attached to that network by name. The
422
+ // name, not the id, is the filter because a created but never started container records its
423
+ // networks without an id, and the filter by id misses it.
424
+ function networkHolderFormat() {
425
+ return '{"id":{{json .ID}},"networks":{{json .Networks}}}';
426
+ }
427
+ const NETWORK_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
428
+ function networkHolderSteps(authority, names, labels) {
429
+ const steps = [];
430
+ for (let start = 0; start < names.length; start += INSPECTION_CHUNK) {
431
+ const chunk = names.slice(start, start + INSPECTION_CHUNK);
432
+ steps.push(exactStep(`runtime:network-holders:${digestValue(chunk).slice(0, 16)}`, [authority.executable.canonicalPath, '--host', authority.selection.endpoint, 'container', 'ls', '--all', '--no-trunc',
433
+ ...chunk.flatMap((name) => ['--filter', `network=${name}`]), '--format', networkHolderFormat()], authority.executable, authority.cwd, authority.env, 30_000, [], { ...labels, networkCount: String(chunk.length) }, [], OUTPUT_LIMIT));
434
+ }
435
+ return steps;
436
+ }
437
+ function holderRows(stdout) {
438
+ const text = stdout.toString('utf8');
439
+ if (!Buffer.from(text, 'utf8').equals(stdout))
440
+ return undefined;
441
+ const rows = [];
442
+ for (const line of text.split('\n')) {
443
+ if (line.length === 0)
444
+ continue;
445
+ // Unparseable docker output is unproven; the caller refuses the discovery.
446
+ const row = tryParseJson(line);
447
+ if (row === null || typeof row !== 'object' || Array.isArray(row) || !exactKeys(row, ['id', 'networks']))
448
+ return undefined;
449
+ const { id, networks } = row;
450
+ if (typeof id !== 'string' || !RESOURCE_ID.test(id) || typeof networks !== 'string')
451
+ return undefined;
452
+ const names = networks.split(',').filter(Boolean);
453
+ if (names.length > MAX_RESOURCES || names.some((name) => !NETWORK_NAME.test(name)))
454
+ return undefined;
455
+ rows.push({ id, networks: names });
456
+ }
457
+ return rows.length > MAX_RESOURCES || new Set(rows.map(({ id }) => id)).size !== rows.length ? undefined : rows;
458
+ }
459
+ /** Complete, empty-stderr output of one step, or nothing. */
460
+ function cleanOutput(outcome) {
461
+ return outcome.kind === 'complete' && outcome.stderr.byteLength === 0 ? outcome : undefined;
462
+ }
463
+ function utf8(bytes) {
464
+ const text = bytes.toString('utf8');
465
+ return Buffer.from(text, 'utf8').equals(bytes) ? text : undefined;
466
+ }
467
+ function boundedExecutor(execute, concurrency) {
468
+ let active = 0;
469
+ const waiters = [];
470
+ return async (step) => {
471
+ if (active < concurrency)
472
+ active += 1;
473
+ else
474
+ await new Promise(resolveWaiter => waiters.push(resolveWaiter));
475
+ try {
476
+ return await execute(step);
477
+ }
478
+ finally {
479
+ const next = waiters.shift();
480
+ if (next)
481
+ next();
482
+ else
483
+ active -= 1;
484
+ }
485
+ };
486
+ }
487
+ async function selectDocker(input, executable, env, execute) {
488
+ // Only the selection reads DOCKER_HOST; discovery and actions address the admitted endpoint
489
+ // with --host, so every other step keeps a daemon-free environment.
490
+ const selectionEnv = input.host === undefined ? env : { ...env, DOCKER_HOST: input.host };
491
+ const step = exactStep('runtime:select:context', [executable.canonicalPath, 'context', 'inspect', input.context, '--format', contextSelectionFormat], executable, input.cwd, selectionEnv, input.timeoutMs, [], { selectionKind: 'context' });
492
+ if (step === undefined)
493
+ return undefined;
494
+ const outcome = cleanOutput(await execute(step));
495
+ const row = outcome === undefined ? undefined : selectionRow(outcome.stdout);
496
+ if (outcome === undefined || row === undefined || !exactKeys(row, ['context', 'endpoint'])
497
+ || row.context !== input.context
498
+ || typeof row.endpoint !== 'string' || admitLocalDockerEndpoint(row.endpoint) === undefined
499
+ || input.host !== undefined && row.endpoint !== input.host)
500
+ return undefined;
501
+ return { value: { context: input.context, endpoint: row.endpoint }, startedAt: outcome.receipt.startedAt, finishedAt: outcome.receipt.finishedAt };
502
+ }
503
+ async function listResources(runtime, kind, execute) {
504
+ const step = exactStep(`runtime:discover:${kind}`, [runtime.executable.canonicalPath, ...runtimeCommand(kind, runtime.selection, runtime.stackId)], runtime.executable, runtime.cwd, runtime.env, runtime.timeoutMs, [], {
505
+ stackId: runtime.stackId, cockpitId: runtime.cockpitId, resourceKind: kind,
506
+ dockerContext: runtime.selection.context, dockerEndpoint: runtime.selection.endpoint,
507
+ }, [], OUTPUT_LIMIT);
508
+ if (step === undefined)
509
+ return undefined;
510
+ const outcome = cleanOutput(await execute(step));
511
+ const text = outcome === undefined ? undefined : utf8(outcome.stdout);
512
+ if (outcome === undefined || text === undefined)
513
+ return undefined;
514
+ const resources = [];
515
+ for (const line of text.split('\n')) {
516
+ if (line.length === 0)
517
+ continue;
518
+ // Unparseable docker output is unproven; the discovery fails closed.
519
+ const parsed = tryParseJson(line);
520
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
521
+ return undefined;
522
+ const owned = dockerResource(parsed, kind);
523
+ if (owned === undefined)
524
+ return undefined;
525
+ // An image prints one row per tag. The one tag Compose gives an image it builds is this
526
+ // stack's own name for it, so that image is removable; a second tag means some other name
527
+ // was given to it, and that name may still be needed. Treating any tag as a reference
528
+ // kept every image the stack built. A container still using an image keeps it by
529
+ // Docker's own refusal to remove it.
530
+ const duplicate = resources.find(({ id }) => id === owned.id);
531
+ if (duplicate !== undefined && kind === 'image') {
532
+ if (duplicate.bytes !== owned.bytes)
533
+ return undefined;
534
+ resources.splice(resources.indexOf(duplicate), 1, { ...duplicate, referenced: true, name: [duplicate.name, owned.name].sort(lexical)[0] });
535
+ continue;
536
+ }
537
+ resources.push(owned);
538
+ }
539
+ if (resources.length > MAX_RESOURCES || new Set(resources.map(({ id }) => id)).size !== resources.length)
540
+ return undefined;
541
+ return { value: resources.sort((a, b) => lexical(a.id, b.id)), startedAt: outcome.receipt.startedAt, finishedAt: outcome.receipt.finishedAt };
542
+ }
543
+ /** Runs one kind's inspection steps; an oversized object names its chunk instead of failing anonymously. */
544
+ async function runInspections(steps, kind, execute) {
545
+ if (steps.some((step) => step === undefined))
546
+ return undefined;
547
+ const attempts = await Promise.allSettled(steps.map(async (step) => ({ step, outcome: await execute(step) })));
548
+ if (attempts.some(({ status }) => status === 'rejected'))
549
+ return undefined;
550
+ const evidence = attempts.map((attempt) => attempt.value);
551
+ const overflow = evidence.map(({ step, outcome }) => inspectionOverflow(step, outcome)).find((message) => message !== undefined);
552
+ if (overflow !== undefined)
553
+ return refusal(ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT, overflow, kind);
554
+ const clean = evidence.map(({ step, outcome }) => ({ step, outcome: cleanOutput(outcome) }));
555
+ return clean.every(({ outcome }) => outcome !== undefined)
556
+ ? clean : undefined;
557
+ }
558
+ async function inspectImages(runtime, images, execute) {
559
+ const evidence = await runInspections(inspectionSteps(runtime, 'image', imageInspectionFormat(), images.map(({ id }) => id), {
560
+ resourceKind: 'image-inspection', dockerContext: runtime.selection.context, dockerEndpoint: runtime.selection.endpoint,
561
+ }), 'image', execute);
562
+ if (evidence === undefined || 'diagnostics' in evidence)
563
+ return evidence;
564
+ const labels = new Map();
565
+ for (const { step, outcome } of evidence) {
566
+ const ids = inspectedIds(step);
567
+ const rows = inspectionRows(outcome.stdout, ids);
568
+ if (rows === undefined)
569
+ return undefined;
570
+ for (const [position, record] of rows.entries()) {
571
+ const resourceId = ids[position];
572
+ const observedLabels = exactKeys(record, ['id', 'labels']) ? dockerLabels(record.labels) : undefined;
573
+ if (record.id !== resourceId || observedLabels === undefined || labels.has(resourceId))
574
+ return undefined;
575
+ labels.set(resourceId, observedLabels);
576
+ }
577
+ }
578
+ return { value: images.map((resource) => snapshotResource({ ...resource, labels: labels.get(resource.id) ?? resource.labels })), finishedAt: latest(evidence) };
579
+ }
580
+ async function inspectVolumes(runtime, volumes, execute) {
581
+ // Only a volume this stack labels can become a target, so only its generation is read.
582
+ const owned = volumes.filter(({ labels }) => labels[REQUIRED_LABELS.stack] === runtime.stackId);
583
+ const evidence = await runInspections(inspectionSteps(runtime, 'volume', `{"name":{{json .Name}},"generation":{{with .Labels}}{{json (or (index . "dev.b0dea.cockpit.generation-id") "")}}{{else}}""{{end}}}`, owned.map(({ id }) => id), { resourceKind: 'volume-inspection' }), 'volume', execute);
584
+ if (evidence === undefined || 'diagnostics' in evidence)
585
+ return evidence;
586
+ const generations = new Map();
587
+ for (const { step, outcome } of evidence) {
588
+ const ids = inspectedIds(step);
589
+ const rows = inspectionRows(outcome.stdout, ids);
590
+ if (rows === undefined)
591
+ return undefined;
592
+ for (const [position, record] of rows.entries()) {
593
+ if (!exactKeys(record, ['name', 'generation']))
594
+ return undefined;
595
+ const { name, generation } = record;
596
+ if (name !== ids[position] || typeof generation !== 'string'
597
+ || generation.length > 128 || generation.includes('\0') || generations.has(name))
598
+ return undefined;
599
+ if (generation !== '' && !SHA256.test(generation))
600
+ return undefined;
601
+ generations.set(name, generation);
602
+ }
603
+ }
604
+ return {
605
+ value: volumes.map((resource) => {
606
+ const generation = generations.get(resource.id);
607
+ return snapshotResource({ ...resource, ...(generation === undefined || generation === '' ? {} : { generation }) });
608
+ }),
609
+ finishedAt: latest(evidence),
610
+ };
611
+ }
612
+ async function inspectNetworks(runtime, networks, containers, execute) {
613
+ const selection = { dockerContext: runtime.selection.context, dockerEndpoint: runtime.selection.endpoint };
614
+ const names = [...new Set(networks.map(({ name }) => name))].sort(lexical);
615
+ if (names.some((name) => !NETWORK_NAME.test(name)))
616
+ return undefined;
617
+ const evidence = await runInspections([
618
+ ...inspectionSteps(runtime, 'network', networkInspectionFormat(), networks.map(({ id }) => id), { resourceKind: 'network-inspection', ...selection }),
619
+ ...networkHolderSteps(runtime, names, { resourceKind: 'network-holder-listing', ...selection }),
620
+ ], 'network', execute);
621
+ if (evidence === undefined || 'diagnostics' in evidence)
622
+ return evidence;
623
+ const containersById = new Map(containers.map((container) => [container.id, container]));
624
+ const holders = new Map();
625
+ const inspectedNetworks = new Set();
626
+ const hold = (networkId, containerId) => {
627
+ holders.set(networkId, (holders.get(networkId) ?? new Set()).add(containerId));
628
+ };
629
+ for (const { step, outcome } of evidence) {
630
+ if (step.labels.resourceKind === 'network-inspection') {
631
+ const ids = inspectedIds(step);
632
+ const rows = inspectionRows(outcome.stdout, ids);
633
+ if (rows === undefined)
634
+ return undefined;
635
+ for (const [position, record] of rows.entries()) {
636
+ const resourceId = ids[position];
637
+ if (!exactKeys(record, ['id', 'members']) || record.id !== resourceId
638
+ || typeof record.members !== 'string' || inspectedNetworks.has(resourceId))
639
+ return undefined;
640
+ const members = record.members.split(' ').filter(Boolean);
641
+ if (members.length > MAX_RESOURCES || members.some((member) => !RESOURCE_ID.test(member)))
642
+ return undefined;
643
+ inspectedNetworks.add(resourceId);
644
+ for (const member of members)
645
+ hold(resourceId, member);
646
+ }
647
+ }
648
+ else {
649
+ const rows = holderRows(outcome.stdout);
650
+ if (rows === undefined)
651
+ return undefined;
652
+ for (const row of rows) {
653
+ // A created but never started container (as an interrupted Compose recreate leaves
654
+ // its replacement) still names its networks, so it holds every discovered network
655
+ // of that name; refusing the whole discovery left `down` unable to remove the
656
+ // stack that `up` had started.
657
+ for (const name of row.networks) {
658
+ for (const network of networks)
659
+ if (network.name === name)
660
+ hold(network.id, row.id);
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return {
666
+ value: networks.map((resource) => snapshotResource({
667
+ ...resource, referenced: [...holders.get(resource.id) ?? []].some((holder) => !sameStackHolder(resource, containersById.get(holder))),
668
+ })),
669
+ finishedAt: latest(evidence),
670
+ };
671
+ }
672
+ function latest(evidence) {
673
+ return evidence.map(({ outcome }) => outcome.receipt.finishedAt).sort(lexical).at(-1);
674
+ }
675
+ function sameSelection(left, right) {
676
+ return left.context === right.context && left.endpoint === right.endpoint;
677
+ }
678
+ async function discoverReclaimRound(input, executable, env, execute) {
679
+ // The selection is read before and after the listings, and again after the inspections, so the
680
+ // context's endpoint cannot have moved to another daemon while this stack was being read.
681
+ const planned = await selectDocker(input, executable, env, execute);
682
+ if (planned === undefined)
683
+ return undefined;
684
+ const runtime = {
685
+ executable, cwd: input.cwd, env, timeoutMs: input.timeoutMs,
686
+ stackId: input.stackId, cockpitId: input.cockpitId, selection: planned.value,
687
+ };
688
+ const listed = await Promise.all(DISCOVERY_KINDS.map((kind) => listResources(runtime, kind, execute)));
689
+ if (listed.some((value) => value === undefined))
690
+ return undefined;
691
+ const lists = listed;
692
+ const listedAt = lists.map(({ finishedAt }) => finishedAt).sort(lexical).at(-1);
693
+ const current = await selectDocker(input, executable, env, execute);
694
+ if (current === undefined || !sameSelection(current.value, planned.value)
695
+ || Date.parse(current.startedAt) < Date.parse(listedAt)
696
+ || Date.parse(current.finishedAt) - Date.parse(listedAt) > 30_000)
697
+ return undefined;
698
+ const [containers, networks, volumes, images, buildCache] = lists.map(({ value }) => value);
699
+ const inspect = boundedExecutor(execute, MAX_CONCURRENT_INSPECTIONS);
700
+ const inspected = await Promise.all([
701
+ inspectNetworks(runtime, networks, containers, inspect),
702
+ inspectVolumes(runtime, volumes, inspect),
703
+ inspectImages(runtime, images, inspect),
704
+ ]);
705
+ const refused = inspected.find((value) => value !== undefined && 'diagnostics' in value);
706
+ if (refused)
707
+ return refused;
708
+ if (inspected.some((value) => value === undefined))
709
+ return undefined;
710
+ const [inspectedNetworks, inspectedVolumes, inspectedImages] = inspected;
711
+ const resources = [...containers, ...inspectedNetworks.value, ...inspectedVolumes.value, ...inspectedImages.value, ...buildCache]
712
+ .sort((left, right) => lexical(left.id, right.id));
713
+ if (resources.length > MAX_RESOURCES || new Set(resources.map(({ id }) => id)).size !== resources.length)
714
+ return undefined;
715
+ const startedAt = lists.map(({ startedAt: listStarted }) => listStarted).sort(lexical)[0];
716
+ const finishedAt = [listedAt, ...inspected.map((value) => value.finishedAt)]
717
+ .filter((value) => value !== undefined).sort(lexical).at(-1);
718
+ if (Date.parse(finishedAt) - Date.parse(startedAt) > 30_000)
719
+ return undefined;
720
+ const confirmed = await selectDocker(input, executable, env, execute);
721
+ if (confirmed === undefined || !sameSelection(confirmed.value, planned.value)
722
+ || Date.parse(confirmed.startedAt) < Date.parse(finishedAt))
723
+ return undefined;
724
+ return { observedAt: finishedAt, producer: 'runtime-resource-discovery', resources, startedAt, stackId: input.stackId, selection: planned.value };
725
+ }
726
+ /**
727
+ * Lists and inspects this stack's runtime resources on the selected Docker context. Discovery
728
+ * lists resources and then inspects what it listed, and a listed resource can vanish in between,
729
+ * as Compose replacing one of this stack's containers does; the round then fails closed on an id
730
+ * that no longer exists, and inside a reserved drain that left the attempt for repair. One more
731
+ * complete round covers that race without admitting partial evidence.
732
+ */
733
+ export async function discoverReclaim(input, execute) {
734
+ const executable = resolvedDockerExecutable(input.executable);
735
+ const env = safeEnvironment(input.env);
736
+ if (executable === undefined || env === undefined || !DOCKER_SELECTOR.test(input.context)
737
+ || input.host !== undefined && (admitLocalDockerEndpoint(input.host) === undefined || input.context !== 'default')
738
+ || !STACK_ID.test(input.stackId) || !COCKPIT_ID.test(input.cockpitId)
739
+ || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs))
740
+ return undefined;
741
+ return await discoverReclaimRound(input, executable, env, execute)
742
+ ?? await discoverReclaimRound(input, executable, env, execute);
743
+ }
744
+ function refusal(code, message, target, fix) {
745
+ return { kind: 'refusal', diagnostics: [diagnostic(code, message, target, fix)] };
746
+ }
747
+ function canonicalValue(value) {
748
+ if (Array.isArray(value))
749
+ return value.map(canonicalValue);
750
+ if (value !== null && typeof value === 'object') {
751
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => lexical(left, right))
752
+ .map(([key, item]) => [key, canonicalValue(item)]));
753
+ }
754
+ return value;
755
+ }
756
+ function digestValue(value) {
757
+ return createHash('sha256').update(JSON.stringify(canonicalValue(value))).digest('hex');
758
+ }
759
+ function validExecutionInput(executable, cwd, env, timeoutMs) {
760
+ const safeEnv = safeEnvironment(env);
761
+ return resolvedExecutable(executable) !== undefined
762
+ && typeof cwd === 'string' && isAbsolute(cwd) && normalize(cwd) === cwd && !cwd.includes('\0')
763
+ && safeEnv !== undefined
764
+ && Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 30_000;
765
+ }
766
+ async function removalExecutable(value) {
767
+ const executable = resolvedExecutable(value);
768
+ if (executable === undefined || executable.invocationPath !== '/bin/rm' || executable.executionMode !== 'verified-path')
769
+ return undefined;
770
+ const canonical = await realpath('/bin/rm');
771
+ const bytes = await readFile(canonical);
772
+ return canonical === executable.canonicalPath
773
+ && createHash('sha256').update(bytes).digest('hex') === executable.sha256 ? executable : undefined;
774
+ }
775
+ // A discovery lists only one stack's resources, so a discovery of another stack would make this
776
+ // one look empty: every planned target absent and every removal confirmed. It must name this stack.
777
+ function discoveryOfStack(discovery, stackId) {
778
+ return discovery.stackId === stackId;
779
+ }
780
+ // The plan acts only on a discovery read after the approved one, so what is removed is what the
781
+ // approved digest names in the daemon's current state, not a replay of the state that was shown.
782
+ function followsConsent(consent, digest, discovery) {
783
+ return consent !== undefined && consent.digest === digest
784
+ && Date.parse(discovery.startedAt) > Date.parse(consent.observedAt);
785
+ }
786
+ // After a removal, the discovery that reconciled it may plan the next one. After a removal that
787
+ // never started, nothing proved the state since, so the discovery must follow the published failure.
788
+ function followsContinuation(discovery, continuation, failed, record) {
789
+ return failed ? Date.parse(discovery.startedAt) > Date.parse(record.updatedAt)
790
+ : Date.parse(discovery.startedAt) >= Date.parse(continuation.freshAfter);
791
+ }
792
+ function exactStep(id, argv, executable, cwd, env, timeoutMs, paths, labels, filesystemChecks = [], stdoutLimitBytes = 65_536) {
793
+ return snapshotExecutionStep({
794
+ id,
795
+ argv,
796
+ argv0: executable.invocationPath,
797
+ cwd,
798
+ env,
799
+ timeoutMs,
800
+ stdoutLimitBytes,
801
+ stderrLimitBytes: 65_536,
802
+ paths,
803
+ ports: [],
804
+ labels,
805
+ expected: [],
806
+ consumedFiles: [{
807
+ path: executable.canonicalPath,
808
+ sha256: executable.sha256,
809
+ maxBytes: 536_870_912,
810
+ argvIndexes: [0],
811
+ executable: true,
812
+ executionMode: executable.executionMode,
813
+ }],
814
+ filesystemChecks,
815
+ compensation: [],
816
+ });
817
+ }
818
+ async function inspectHostPath(declaration) {
819
+ if (!declaration || typeof declaration.root !== 'string' || typeof declaration.path !== 'string'
820
+ || typeof declaration.declaration !== 'string' || declaration.declaration.length === 0 || declaration.declaration.includes('\0')
821
+ || !isAbsolute(declaration.root) || normalize(declaration.root) !== declaration.root
822
+ || !isAbsolute(declaration.path) || normalize(declaration.path) !== declaration.path)
823
+ return undefined;
824
+ const relation = relative(declaration.root, declaration.path);
825
+ if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`) || isAbsolute(relation))
826
+ return undefined;
827
+ try {
828
+ const rootStatus = await lstat(declaration.root, { bigint: true });
829
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink() || await realpath(declaration.root) !== declaration.root)
830
+ return undefined;
831
+ const ancestors = [{
832
+ path: declaration.root, ...filesystemObjectIdentity(rootStatus),
833
+ }];
834
+ let cursor = declaration.root;
835
+ const parts = relation.split(sep);
836
+ for (let index = 0; index < parts.length; index += 1) {
837
+ cursor = resolve(cursor, parts[index]);
838
+ try {
839
+ const status = await lstat(cursor, { bigint: true });
840
+ if (status.isSymbolicLink())
841
+ return undefined;
842
+ if (index < parts.length - 1 && !status.isDirectory())
843
+ return undefined;
844
+ if (index === parts.length - 1) {
845
+ if (await realpath(cursor) !== cursor)
846
+ return undefined;
847
+ return {
848
+ state: 'present', type: status.isDirectory() ? 'directory' : status.isFile() ? 'file' : undefined,
849
+ ...filesystemObjectIdentity(status), mode: Number(status.mode),
850
+ size: Number(status.size), mtimeMs: Number(status.mtimeNs) / 1_000_000, ancestors,
851
+ };
852
+ }
853
+ ancestors.push({ path: cursor, ...filesystemObjectIdentity(status) });
854
+ }
855
+ catch (error) {
856
+ if (!hasErrorCode(error, 'ENOENT'))
857
+ return undefined;
858
+ return { state: 'absent' };
859
+ }
860
+ }
861
+ }
862
+ catch {
863
+ // A declared path that cannot be identified is refused by the caller as an unidentified target.
864
+ return undefined;
865
+ }
866
+ return undefined;
867
+ }
868
+ async function dependencyCacheAuthorities(declarations) {
869
+ if (declarations === undefined)
870
+ return [];
871
+ if (!Array.isArray(declarations) || declarations.length > MAX_RESOURCES)
872
+ return undefined;
873
+ const authorities = [];
874
+ for (const declaration of declarations) {
875
+ if (declaration === null || typeof declaration !== 'object' || !exactKeys(declaration, ['cacheId', 'root', 'path'])
876
+ || typeof declaration.cacheId !== 'string' || !SHA256.test(declaration.cacheId)
877
+ || typeof declaration.root !== 'string' || typeof declaration.path !== 'string'
878
+ || resolve(declaration.root, declaration.cacheId) !== declaration.path)
879
+ return undefined;
880
+ const evidence = await inspectHostPath({ ...declaration, declaration: declaration.cacheId });
881
+ if (evidence === undefined || evidence.state === 'present' && evidence.type !== 'directory')
882
+ return undefined;
883
+ authorities.push({ ...declaration, evidence });
884
+ }
885
+ if (new Set(authorities.map(({ cacheId }) => cacheId)).size !== authorities.length
886
+ || new Set(authorities.map(({ path }) => path)).size !== authorities.length)
887
+ return undefined;
888
+ return authorities.sort((left, right) => lexical(left.cacheId, right.cacheId));
889
+ }
890
+ function safeTarget(value) {
891
+ return {
892
+ kind: value.kind,
893
+ identity: value.identity,
894
+ declaration: value.declaration,
895
+ ...(value.labels === undefined ? {} : { labels: { ...value.labels } }),
896
+ };
897
+ }
898
+ function reclaimInput(input) {
899
+ return {
900
+ stackId: input.stackId,
901
+ cockpitId: input.cockpitId,
902
+ configSha256: input.configSha256,
903
+ coordinationRecord: input.coordinationRecord,
904
+ declaredCacheIds: input.declaredCacheIds,
905
+ discovery: input.discovery,
906
+ };
907
+ }
908
+ async function resetAuthorities(input) {
909
+ if (!Array.isArray(input.resettableVolumes) || !Array.isArray(input.generatedHostPaths)
910
+ || input.resettableVolumes.length + input.generatedHostPaths.length > MAX_RESOURCES
911
+ || input.resettableVolumes.some((name) => typeof name !== 'string' || !/^[a-z0-9][a-z0-9_.-]*$/.test(name))
912
+ || new Set(input.resettableVolumes).size !== input.resettableVolumes.length
913
+ || typeof input.composeProject !== 'string' || !COMPOSE_PROJECT.test(input.composeProject))
914
+ return undefined;
915
+ const classified = classifyReclaim(reclaimInput(input));
916
+ if (classified.kind !== 'complete')
917
+ return undefined;
918
+ const targets = [];
919
+ for (const key of input.resettableVolumes) {
920
+ // A volume carrying Compose's default name for this key without its labels is one Compose
921
+ // would adopt for the stack, so it counts as a match and refuses below instead of hiding.
922
+ const matches = classified.resources.filter((resource) => resource.kind === 'volume'
923
+ && (resource.labels[COMPOSE_LABELS.project] === input.composeProject && resource.labels[COMPOSE_LABELS.volume] === key
924
+ || resource.name === `${input.composeProject}_${key}`));
925
+ // Configuration validation and the ownership render refuse a key no volume declares, so
926
+ // no match here is a volume not created yet or already removed.
927
+ if (matches.length === 0)
928
+ continue;
929
+ if (matches.length !== 1)
930
+ return undefined;
931
+ const match = matches[0];
932
+ // The stack and owner labels identify the stack; the configuration label only records
933
+ // which cockpit.toml created the volume. Compose keeps a volume across configuration
934
+ // edits and `up` goes on using it, so admitting only the current configuration left every
935
+ // data volume un-resettable after the first edit. The preview still commits the exact
936
+ // volume id, labels and generation, so a volume replaced before the action is refused.
937
+ if (match.ownership !== 'current' && match.ownership !== 'stale-config' || match.referenced
938
+ || match.reversibility !== 'data-bearing' || typeof match.generation !== 'string')
939
+ return undefined;
940
+ targets.push({
941
+ kind: 'volume', identity: match.name, declaration: key, labels: { ...match.labels },
942
+ evidence: { resourceId: match.id, bytes: match.bytes, generation: match.generation },
943
+ });
944
+ }
945
+ // Docker refuses to remove a volume a container still references, stopped or not, and the
946
+ // runtime stop leaves the stack's containers in place. So a reset that removes volumes first
947
+ // removes the containers the same ownership evidence names as this stack's own; containers
948
+ // sort before volumes, so they are removed first. Without them every approved reset failed
949
+ // with "volume is in use".
950
+ if (targets.length > 0) {
951
+ for (const match of classified.resources) {
952
+ if (match.kind !== 'container' || match.ownership !== 'current' && match.ownership !== 'stale-config'
953
+ || match.action !== 'teardown')
954
+ continue;
955
+ const { ownership: _ownership, reversibility: _reversibility, action: _action, evidence: _evidence, reason: _reason, suggestion: _suggestion, ...resource } = match;
956
+ targets.push({
957
+ kind: 'container', identity: match.id, declaration: match.name, labels: { ...match.labels },
958
+ evidence: { resource },
959
+ });
960
+ }
961
+ }
962
+ const hostIdentities = new Set();
963
+ for (const declaration of input.generatedHostPaths) {
964
+ const evidence = await inspectHostPath(declaration);
965
+ if (evidence === undefined || evidence.state === 'present' && evidence.type === undefined || hostIdentities.has(declaration.path))
966
+ return undefined;
967
+ hostIdentities.add(declaration.path);
968
+ targets.push({
969
+ kind: 'host-path', identity: declaration.path, declaration: declaration.declaration,
970
+ root: declaration.root, evidence,
971
+ });
972
+ }
973
+ return targets.sort((left, right) => lexical(left.kind, right.kind) || lexical(left.identity, right.identity));
974
+ }
975
+ function coordinationForDestruction(input) {
976
+ const record = input.coordinationRecord;
977
+ if (record === null || record.cockpitId !== input.cockpitId || record.revision !== input.expectedCoordinationRevision) {
978
+ return { refusal: refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'coordination revision does not match the destructive request', input.stackId) };
979
+ }
980
+ const coordination = decodeCoordinationRecord(record, input.stackId);
981
+ if (coordination === undefined)
982
+ return { refusal: refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination evidence is malformed', input.stackId) };
983
+ const otherHolders = coordination.holders.filter(({ holderId }) => holderId !== input.requesterHolderId);
984
+ if (otherHolders.length > 0 || input.requesterHolderId !== null && !coordination.holders.some(({ holderId }) => holderId === input.requesterHolderId)) {
985
+ return { refusal: refusal(ENVIRONMENT_CODES.RESET_CO_TENANT, 'reset requires an exact requester claim and zero co-tenants', input.stackId) };
986
+ }
987
+ return { revision: record.revision };
988
+ }
989
+ function coordinationIsFresh(record, observedAt) {
990
+ const observed = Date.parse(observedAt);
991
+ const now = Date.now();
992
+ return Number.isFinite(Date.parse(record.updatedAt)) && Number.isFinite(observed)
993
+ && now >= observed && now - observed <= 30_000;
994
+ }
995
+ function coordinationWorkBasis(record, stackId) {
996
+ const value = decodeCoordinationRecord(record, stackId);
997
+ return value === undefined ? undefined : {
998
+ holders: value.holders,
999
+ lease: value.lease,
1000
+ lastReleasedHolder: value.lastReleasedHolder,
1001
+ };
1002
+ }
1003
+ async function rereadCoordination(address, expected, observedAt) {
1004
+ if (address === undefined || !coordinationIsFresh(expected, observedAt))
1005
+ return undefined;
1006
+ const result = await readState(address);
1007
+ if (result.kind !== 'complete' || !coordinationIsFresh(result.value, observedAt)
1008
+ || digestValue(result.value) !== digestValue(expected))
1009
+ return undefined;
1010
+ return result.value;
1011
+ }
1012
+ export async function planReset(input) {
1013
+ try {
1014
+ const { discovery, consent, continuation, recoveredReservation, operatorApproval } = input;
1015
+ const runtimeExecutable = resolvedDockerExecutable(input.runtimeExecutable);
1016
+ const pathExecutable = await removalExecutable(input.pathExecutable);
1017
+ const env = safeEnvironment(input.env);
1018
+ if (runtimeExecutable === undefined || pathExecutable === undefined || env === undefined
1019
+ || !validExecutionInput(runtimeExecutable, input.cwd, input.env, input.timeoutMs)
1020
+ || !validExecutionInput(pathExecutable, input.cwd, input.env, input.timeoutMs)) {
1021
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset execution authority is malformed', 'reset');
1022
+ }
1023
+ if (!discoveryOfStack(discovery, input.stackId)) {
1024
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset requires a fresh runtime discovery of this stack', 'reset-discovery');
1025
+ }
1026
+ const selection = discovery.selection;
1027
+ const coordination = coordinationForDestruction(input);
1028
+ if ('refusal' in coordination)
1029
+ return coordination.refusal;
1030
+ const coldPlanningRecovery = continuation === undefined && consent === undefined && recoveredReservation !== undefined
1031
+ ? inspectFailedReclaimRecovery({ reservation: recoveredReservation, record: input.coordinationRecord }) : undefined;
1032
+ if ((recoveredReservation !== undefined) !== (operatorApproval !== undefined)
1033
+ || operatorApproval !== undefined && coldPlanningRecovery === undefined) {
1034
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'cold reset recovery requires an authenticated approval of the remaining targets', 'reset-confirmation');
1035
+ }
1036
+ const coldPlanningIdentities = coldPlanningRecovery === undefined
1037
+ ? undefined : committedTargetIdentities(coldPlanningRecovery);
1038
+ const observedAuthorities = await resetAuthorities(input);
1039
+ if (observedAuthorities === undefined)
1040
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset targets do not match current declarations and ownership evidence', 'reset-targets');
1041
+ if (continuation !== undefined && continuation.purpose !== 'reset') {
1042
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation receipt is invalid', 'reset-continuation');
1043
+ }
1044
+ const continued = continuation;
1045
+ let authorities = observedAuthorities;
1046
+ let continuedAfterFailure = false;
1047
+ if (continued !== undefined) {
1048
+ const match = matchReclaimDrainContinuation({ continuation: continued.drain, record: input.coordinationRecord });
1049
+ if (match === 'unmatched') {
1050
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation no longer matches the remaining consented target set', 'reset-continuation');
1051
+ }
1052
+ continuedAfterFailure = match === 'failed';
1053
+ const completedTargetIds = new Set(continuedAfterFailure
1054
+ ? continued.removedTargetIds
1055
+ : [...continued.removedTargetIds, continued.completedTargetId]);
1056
+ const remainingNow = continuedAfterFailure ? observedAuthorities
1057
+ : observedAuthorities.filter(({ kind, identity }) => `${kind}:${identity}` !== continued.completedTargetId);
1058
+ const remainingTargetIds = continued.targetIds.filter((targetId) => !completedTargetIds.has(targetId));
1059
+ const remainingIdentities = resetTargetIdentities(remainingTargetIds, remainingNow);
1060
+ const completedRemainAbsent = continued.targetIdentities
1061
+ .filter(({ targetId }) => completedTargetIds.has(targetId))
1062
+ .every((identity) => targetIsAbsent(identity, discovery.resources));
1063
+ if (!followsContinuation(discovery, continued, continuedAfterFailure, input.coordinationRecord)
1064
+ || !completedRemainAbsent || remainingIdentities === undefined
1065
+ || !sameTargetProjections(remainingIdentities, continued.targetIdentities, remainingTargetIds)) {
1066
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation no longer matches the remaining consented target set', 'reset-continuation');
1067
+ }
1068
+ authorities = remainingNow;
1069
+ }
1070
+ const digestTargetIds = authorities
1071
+ .filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present')
1072
+ .map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1073
+ const digestIdentities = coldPlanningIdentities ?? resetTargetIdentities(digestTargetIds, authorities);
1074
+ const coldCurrentIdentity = coldPlanningIdentities?.find(({ targetId }) => targetId === coldPlanningRecovery?.current.targetId);
1075
+ const coldCurrentAbsent = coldCurrentIdentity !== undefined && targetIsAbsent(coldCurrentIdentity, discovery.resources);
1076
+ if (coldCurrentAbsent && (coldCurrentIdentity.medium === 'path'
1077
+ ? !input.generatedHostPaths.some(declaration => declaration.root === coldCurrentIdentity.root
1078
+ && declaration.path === coldCurrentIdentity.path && declaration.declaration === coldCurrentIdentity.declaration)
1079
+ : coldCurrentIdentity.scope === 'reset-volume' && !input.resettableVolumes.includes(coldCurrentIdentity.declaration))) {
1080
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'interrupted reset target is no longer declared', 'reset-targets');
1081
+ }
1082
+ const digest = continued?.digest ?? digestValue({
1083
+ format: 'cockpit-reset-preview/v1', stackId: input.stackId, cockpitId: input.cockpitId,
1084
+ configSha256: input.configSha256,
1085
+ coordination: coordinationWorkBasis(input.coordinationRecord, input.stackId),
1086
+ targets: digestIdentities,
1087
+ runtime: { executable: runtimeExecutable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs },
1088
+ });
1089
+ const runtimeDigest = digestValue({ executable: runtimeExecutable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs });
1090
+ if (continued !== undefined && continued.runtimeDigest !== runtimeDigest) {
1091
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation does not match the original runtime authority', 'reset-continuation');
1092
+ }
1093
+ const publicTargets = authorities.map(safeTarget);
1094
+ if (input.confirmationDigest === undefined) {
1095
+ const actionable = authorities.filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present');
1096
+ const targetIds = actionable.map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1097
+ const identities = coldPlanningIdentities ?? resetTargetIdentities(targetIds, actionable);
1098
+ if (identities === undefined) {
1099
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset target identities could not be committed exactly', 'reset-targets');
1100
+ }
1101
+ return {
1102
+ kind: 'preview', digest, coordinationRevision: coordination.revision,
1103
+ targets: publicTargets, noop: authorities.length === 0 && !coldCurrentAbsent,
1104
+ consent: { digest, observedAt: discovery.observedAt },
1105
+ envelope: reclaimTargetEnvelope('reset', input.stackId, input.configSha256, digest, targetCommitments(identities)),
1106
+ };
1107
+ }
1108
+ if (!SHA256.test(input.confirmationDigest) || input.confirmationDigest !== digest) {
1109
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset consent does not match the fresh complete preview', 'reset-confirmation');
1110
+ }
1111
+ const currentCoordination = input.coordinationRecord === null ? undefined
1112
+ : await rereadCoordination(input.coordinationAddress, input.coordinationRecord, input.discovery.observedAt);
1113
+ if (currentCoordination === undefined) {
1114
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'reset requires fresh coordination evidence read at the destructive boundary', input.stackId);
1115
+ }
1116
+ const boundaryCoordination = coordinationForDestruction({ ...input, coordinationRecord: currentCoordination });
1117
+ if ('refusal' in boundaryCoordination)
1118
+ return boundaryCoordination.refusal;
1119
+ const coldRecovery = coldPlanningRecovery;
1120
+ const coldIdentities = coldRecovery === undefined ? undefined : committedTargetIdentities(coldRecovery);
1121
+ const coldReclaimTargetIds = coldRecovery === undefined ? undefined
1122
+ : [...coldRecovery.completed, coldRecovery.current, ...coldRecovery.pending].map(({ targetId }) => targetId);
1123
+ const coldCompletedTargetIds = coldRecovery?.completed.map(({ targetId }) => targetId);
1124
+ const coldRemainingTargetIds = coldRecovery === undefined ? undefined
1125
+ : (coldCurrentAbsent ? coldRecovery.pending : [coldRecovery.current, ...coldRecovery.pending]).map(({ targetId }) => targetId);
1126
+ const coldObservedIdentities = coldRemainingTargetIds === undefined ? undefined
1127
+ : resetTargetIdentities(coldRemainingTargetIds, authorities);
1128
+ const coldTargetsMatch = coldRecovery !== undefined && coldIdentities !== undefined
1129
+ && coldRecovery.purpose === 'reset' && coldRecovery.stackId === input.stackId
1130
+ && coldRecovery.configSha256 === input.configSha256 && coldRecovery.planDigest === input.confirmationDigest
1131
+ && (coldCurrentAbsent || coldRecovery.current.targetId === coldRemainingTargetIds?.[0])
1132
+ && sameStrings(digestTargetIds, coldRemainingTargetIds)
1133
+ && coldCompletedTargetIds.every((targetId) => coldIdentities
1134
+ .filter((identity) => identity.targetId === targetId)
1135
+ .every((identity) => targetIsAbsent(identity, discovery.resources)))
1136
+ && coldObservedIdentities !== undefined
1137
+ && sameTargetProjections(coldObservedIdentities, coldIdentities, coldRemainingTargetIds);
1138
+ const followsPersistedFailure = coldTargetsMatch
1139
+ && Date.parse(discovery.startedAt) > Date.parse(coldRecovery.freshAfter);
1140
+ const approval = operatorApproval?.envelope;
1141
+ if (operatorApproval !== undefined && (!coldTargetsMatch || !approval
1142
+ || approval.purpose !== 'reset' || approval.stackId !== input.stackId || approval.configSha256 !== input.configSha256
1143
+ || digestValue(approval.commitments) !== digestValue(targetCommitments(coldIdentities
1144
+ .filter(({ targetId }) => coldRemainingTargetIds.includes(targetId)))))) {
1145
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'approved reset preview does not match the exact remaining persisted targets', 'reset-confirmation');
1146
+ }
1147
+ if (continued === undefined && !(coldTargetsMatch ? followsPersistedFailure : followsConsent(consent, digest, discovery))) {
1148
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset requires a fresh runtime discovery read after the approved preview', 'reset-discovery');
1149
+ }
1150
+ const actionable = authorities.filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present');
1151
+ const targetIds = continued?.targetIds ?? coldReclaimTargetIds ?? actionable.map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1152
+ const removedTargetIds = coldTargetsMatch ? coldCompletedTargetIds : continued === undefined
1153
+ ? []
1154
+ : continuedAfterFailure ? continued.removedTargetIds
1155
+ : [...continued.removedTargetIds, continued.completedTargetId].sort(lexical);
1156
+ const targetIdentities = continued?.targetIdentities ?? (coldTargetsMatch ? coldIdentities : resetTargetIdentities(targetIds, actionable));
1157
+ if (targetIdentities === undefined) {
1158
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset target identities could not be retained exactly', 'reset-targets');
1159
+ }
1160
+ const envelope = reclaimTargetEnvelope('reset', input.stackId, input.configSha256, digest, targetCommitments(coldTargetsMatch ? coldIdentities : targetIdentities));
1161
+ const continuationFor = (completedTargetId) => ({
1162
+ purpose: 'reset', digest, completedTargetId, runtimeDigest, targetIds, removedTargetIds, targetIdentities,
1163
+ freshAfter: new Date().toISOString(), drain: null,
1164
+ });
1165
+ if (coldTargetsMatch && coldCurrentAbsent) {
1166
+ const action = {
1167
+ stackId: input.stackId, configSha256: input.configSha256, step: null,
1168
+ coordinationRecord: currentCoordination, continuation: continuationFor(coldRecovery.current.targetId),
1169
+ observedAt: discovery.observedAt,
1170
+ remainingTargets: async (next) => {
1171
+ const current = await resetAuthorities({ ...input, discovery: next });
1172
+ return current?.filter(candidate => candidate.kind !== 'host-path' || candidate.evidence.state === 'present')
1173
+ .map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1174
+ },
1175
+ };
1176
+ return { kind: 'complete', action, remaining: digestTargetIds.length, noop: false, digest, envelope };
1177
+ }
1178
+ // Revalidation narrows cooperative races; portable process APIs cannot bind a daemon object or pathname descriptor through its final removal syscall.
1179
+ const target = actionable[0];
1180
+ if (target === undefined)
1181
+ return { kind: 'complete', action: null, remaining: 0, noop: true, digest, envelope };
1182
+ const step = target.kind === 'host-path'
1183
+ ? exactStep(`reset:host-path:${digestValue(target.identity).slice(0, 16)}`, [pathExecutable.canonicalPath, '-rf', '--', target.identity], pathExecutable, input.cwd, env, input.timeoutMs, [target.identity], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind }, [{ path: target.identity, expected: target.evidence.type }])
1184
+ : target.kind === 'container' ? exactStep(`reset:container:${digestValue(target.identity).slice(0, 16)}`, [runtimeExecutable.canonicalPath, '--host', selection.endpoint, 'container', 'rm', '--force', '--', target.identity], runtimeExecutable, input.cwd, env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind, container: target.declaration })
1185
+ : exactStep(`reset:volume:${digestValue(target.identity).slice(0, 16)}`, [runtimeExecutable.canonicalPath, '--host', selection.endpoint, 'volume', 'rm', '--', target.evidence.resourceId], runtimeExecutable, input.cwd, env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind, volume: target.identity });
1186
+ if (step === undefined)
1187
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'exact reset action could not cross the execution boundary', target.identity);
1188
+ const action = {
1189
+ stackId: input.stackId, configSha256: input.configSha256, step,
1190
+ coordinationRecord: currentCoordination, continuation: continuationFor(`${target.kind}:${target.identity}`),
1191
+ observedAt: discovery.observedAt,
1192
+ };
1193
+ return { kind: 'complete', action, remaining: actionable.length - 1, noop: false, digest, envelope };
1194
+ }
1195
+ catch (error) {
1196
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, `reset input could not be inspected safely: ${errorCause(error)}`, 'reset');
1197
+ }
1198
+ }
1199
+ function sumBytes(resources) {
1200
+ if (resources.some(({ bytes }) => bytes === null))
1201
+ return null;
1202
+ let total = 0;
1203
+ for (const resource of resources) {
1204
+ total += resource.bytes ?? 0;
1205
+ if (!Number.isSafeInteger(total))
1206
+ return undefined;
1207
+ }
1208
+ return total;
1209
+ }
1210
+ function teardownStep(resource, input, selection, caches, pathExecutable) {
1211
+ if (resource.kind === 'cache') {
1212
+ const cache = caches.find(({ cacheId, evidence }) => cacheId === resource.id && evidence.state === 'present');
1213
+ if (cache === undefined || pathExecutable === undefined)
1214
+ return undefined;
1215
+ return exactStep(`teardown:cache:${digestValue(resource.id).slice(0, 16)}`, [pathExecutable.canonicalPath, '-rf', '--', cache.path], pathExecutable, input.cwd, input.env, input.timeoutMs, [cache.path], { stackId: input.stackId, configSha256: input.configSha256, resourceKind: resource.kind, resourceId: resource.id }, [{ path: cache.path, expected: 'directory' }]);
1216
+ }
1217
+ const command = resource.kind === 'container'
1218
+ ? ['container', 'rm', '--force', '--', resource.id]
1219
+ : resource.kind === 'network'
1220
+ ? ['network', 'rm', '--', resource.id]
1221
+ : resource.kind === 'image'
1222
+ ? ['image', 'rm', '--', resource.id]
1223
+ : resource.kind === 'build-cache'
1224
+ ? ['buildx', 'prune', '--filter', `id=${resource.id}`, '--force']
1225
+ : undefined;
1226
+ if (command === undefined)
1227
+ return undefined;
1228
+ const selected = ['--host', selection.endpoint, ...(resource.kind === 'build-cache' ? ['builder', ...command.slice(1)] : command)];
1229
+ return exactStep(`teardown:${resource.kind}:${digestValue(resource.id).slice(0, 16)}`, [input.executable.canonicalPath, ...selected], input.executable, input.cwd, input.env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resourceKind: resource.kind, resourceId: resource.id });
1230
+ }
1231
+ function actionableReclaimResources(resources) {
1232
+ return resources.filter((resource) => resource.action === 'teardown'
1233
+ || resource.action === 'reclaim' && (resource.kind === 'build-cache' || resource.kind === 'cache'))
1234
+ .sort((left, right) => lexical(`${left.kind}:${left.id}`, `${right.kind}:${right.id}`));
1235
+ }
1236
+ export async function planTeardown(input) {
1237
+ try {
1238
+ const { discovery, consent, continuation: continued, recoveredReservation } = input;
1239
+ const executable = resolvedDockerExecutable(input.executable);
1240
+ const env = safeEnvironment(input.env);
1241
+ const caches = await dependencyCacheAuthorities(input.dependencyCaches);
1242
+ const pathExecutable = input.dependencyCaches !== undefined && input.dependencyCaches.length > 0
1243
+ ? await removalExecutable(input.pathExecutable) : undefined;
1244
+ if (executable === undefined || env === undefined || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs)) {
1245
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown execution authority is malformed', 'teardown');
1246
+ }
1247
+ if (!discoveryOfStack(discovery, input.stackId)) {
1248
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown requires a fresh runtime discovery of this stack', 'teardown-discovery');
1249
+ }
1250
+ const selection = discovery.selection;
1251
+ if (caches === undefined || input.dependencyCaches !== undefined && input.dependencyCaches.length > 0 && pathExecutable === undefined) {
1252
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'dependency-cache reclaim authority is malformed or unconfined', 'teardown-cache');
1253
+ }
1254
+ if (input.coordinationRecord === null || input.coordinationRecord.cockpitId !== input.cockpitId) {
1255
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown requires current coordinated state evidence', input.stackId);
1256
+ }
1257
+ const coordination = decodeCoordinationRecord(input.coordinationRecord, input.stackId);
1258
+ if (coordination === undefined)
1259
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown coordination evidence is malformed', input.stackId);
1260
+ if (coordination.holders.length > 1 || coordination.holders.length === 1 && (coordination.lease === null || coordination.lease.released)) {
1261
+ return refusal(ENVIRONMENT_CODES.RESET_CO_TENANT, 'teardown requires no more than one retained holder with its lease', input.stackId);
1262
+ }
1263
+ const cacheResources = caches.filter(({ evidence }) => evidence.state === 'present').map(({ cacheId }) => ({
1264
+ id: cacheId, name: cacheId, kind: 'cache', labels: {}, referenced: false, bytes: null,
1265
+ }));
1266
+ const observedDiscovery = {
1267
+ observedAt: discovery.observedAt, producer: discovery.producer,
1268
+ resources: [...discovery.resources, ...cacheResources],
1269
+ };
1270
+ const classified = classifyReclaim({ ...reclaimInput(input), discovery: observedDiscovery });
1271
+ if (classified.kind !== 'complete')
1272
+ return classified;
1273
+ const coldRecovery = continued === undefined && consent === undefined && recoveredReservation !== undefined
1274
+ ? inspectFailedReclaimRecovery({ reservation: recoveredReservation, record: input.coordinationRecord }) : undefined;
1275
+ const coldIdentities = coldRecovery === undefined ? undefined : committedTargetIdentities(coldRecovery);
1276
+ if (continued !== undefined && continued.purpose !== 'down') {
1277
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation receipt is invalid', 'teardown-continuation');
1278
+ }
1279
+ let continuedAfterFailure = false;
1280
+ if (continued !== undefined) {
1281
+ const match = matchReclaimDrainContinuation({ continuation: continued.drain, record: input.coordinationRecord });
1282
+ if (match === 'unmatched') {
1283
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation no longer matches the remaining consented resources', 'teardown-continuation');
1284
+ }
1285
+ continuedAfterFailure = match === 'failed';
1286
+ const completedTargetIds = new Set(continuedAfterFailure
1287
+ ? continued.removedTargetIds
1288
+ : [...continued.removedTargetIds, continued.completedTargetId]);
1289
+ const remainingActionable = actionableReclaimResources(classified.resources);
1290
+ const remainingCaches = caches.filter(({ evidence }) => evidence.state === 'present');
1291
+ const remainingTargetIds = continued.targetIds.filter((targetId) => !completedTargetIds.has(targetId));
1292
+ const remainingIdentities = teardownTargetIdentities(remainingTargetIds, remainingActionable, remainingCaches);
1293
+ const completedRemainAbsent = continued.targetIdentities
1294
+ .filter(({ targetId }) => completedTargetIds.has(targetId))
1295
+ .every((identity) => targetIsAbsent(identity, discovery.resources));
1296
+ if (!followsContinuation(discovery, continued, continuedAfterFailure, input.coordinationRecord)
1297
+ || !completedRemainAbsent || remainingIdentities === undefined
1298
+ || !sameTargetProjections(remainingIdentities, continued.targetIdentities, remainingTargetIds)) {
1299
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation no longer matches the remaining consented resources', 'teardown-continuation');
1300
+ }
1301
+ }
1302
+ const actionable = actionableReclaimResources(classified.resources);
1303
+ const freshTargetIds = actionable.map(({ kind, id }) => `${kind}:${id}`).sort(lexical);
1304
+ const freshTargetIdentities = teardownTargetIdentities(freshTargetIds, actionable, caches);
1305
+ if (freshTargetIdentities === undefined) {
1306
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown target identities could not be committed exactly', 'teardown-targets');
1307
+ }
1308
+ const discoveryDigest = continued?.digest ?? digestValue({
1309
+ format: 'cockpit-teardown-discovery/v1', stackId: input.stackId, cockpitId: input.cockpitId,
1310
+ configSha256: input.configSha256,
1311
+ coordination: coordinationWorkBasis(input.coordinationRecord, input.stackId),
1312
+ targets: coldIdentities ?? freshTargetIdentities,
1313
+ runtime: { executable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs },
1314
+ });
1315
+ const runtimeDigest = digestValue({ executable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs });
1316
+ if (continued !== undefined && continued.runtimeDigest !== runtimeDigest) {
1317
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation does not match the original runtime authority', 'teardown-continuation');
1318
+ }
1319
+ if (input.expectedDiscoveryDigest !== undefined && input.expectedDiscoveryDigest !== discoveryDigest) {
1320
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'fresh teardown discovery differs from the accepted snapshot', input.stackId);
1321
+ }
1322
+ let currentCoordination = input.coordinationRecord;
1323
+ if (input.expectedDiscoveryDigest !== undefined) {
1324
+ const current = await rereadCoordination(input.coordinationAddress, input.coordinationRecord, discovery.observedAt);
1325
+ const decodedCurrent = current === undefined ? undefined : decodeCoordinationRecord(current, input.stackId);
1326
+ if (decodedCurrent === undefined || decodedCurrent.holders.length > 1
1327
+ || decodedCurrent.holders.length === 1 && (decodedCurrent.lease === null || decodedCurrent.lease.released)) {
1328
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown requires fresh retained drain coordination evidence read at the destructive boundary', input.stackId);
1329
+ }
1330
+ currentCoordination = current;
1331
+ }
1332
+ const first = actionable[0];
1333
+ const coldReclaimTargetIds = coldRecovery === undefined ? undefined
1334
+ : [...coldRecovery.completed, coldRecovery.current, ...coldRecovery.pending].map(({ targetId }) => targetId);
1335
+ const coldCompletedTargetIds = coldRecovery?.completed.map(({ targetId }) => targetId);
1336
+ const coldCurrentIdentity = coldIdentities?.find(({ targetId }) => targetId === coldRecovery?.current.targetId);
1337
+ const coldCurrentAbsent = coldCurrentIdentity !== undefined && targetIsAbsent(coldCurrentIdentity, discovery.resources);
1338
+ const coldRemainingTargetIds = coldRecovery === undefined ? undefined
1339
+ : (coldCurrentAbsent ? coldRecovery.pending : [coldRecovery.current, ...coldRecovery.pending]).map(({ targetId }) => targetId);
1340
+ const coldObservedIdentities = coldRemainingTargetIds === undefined ? undefined
1341
+ : teardownTargetIdentities(coldRemainingTargetIds, actionable, caches);
1342
+ const coldTargetsMatch = coldRecovery !== undefined && coldIdentities !== undefined
1343
+ && coldRecovery.purpose === 'down' && coldRecovery.stackId === input.stackId
1344
+ && coldRecovery.configSha256 === input.configSha256
1345
+ && coldRecovery.planDigest === discoveryDigest
1346
+ && (input.expectedDiscoveryDigest === undefined || coldRecovery.planDigest === input.expectedDiscoveryDigest)
1347
+ && (coldCurrentAbsent || coldRecovery.current.targetId === coldRemainingTargetIds?.[0])
1348
+ && sameStrings(freshTargetIds, coldRemainingTargetIds)
1349
+ && coldCompletedTargetIds.every((targetId) => coldIdentities
1350
+ .filter((identity) => identity.targetId === targetId)
1351
+ .every((identity) => targetIsAbsent(identity, discovery.resources)))
1352
+ && coldObservedIdentities !== undefined
1353
+ && sameTargetProjections(coldObservedIdentities, coldIdentities, coldRemainingTargetIds);
1354
+ const followsPersistedFailure = coldTargetsMatch
1355
+ && Date.parse(discovery.startedAt) > Date.parse(coldRecovery.freshAfter);
1356
+ if ((first !== undefined || coldCurrentAbsent) && input.expectedDiscoveryDigest !== undefined
1357
+ && continued === undefined && !(coldTargetsMatch ? followsPersistedFailure : followsConsent(consent, discoveryDigest, discovery))) {
1358
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown requires a fresh runtime discovery read after the accepted snapshot', 'teardown-discovery');
1359
+ }
1360
+ const absent = coldTargetsMatch && coldCurrentAbsent && input.expectedDiscoveryDigest !== undefined;
1361
+ const step = absent || first === undefined || input.expectedDiscoveryDigest === undefined
1362
+ ? undefined : teardownStep(first, { ...input, executable, env }, selection, caches, pathExecutable);
1363
+ if (!absent && first !== undefined && input.expectedDiscoveryDigest !== undefined && step === undefined) {
1364
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'exact teardown action could not cross the execution boundary', first.id);
1365
+ }
1366
+ const raw = observedDiscovery.resources;
1367
+ const observed = {
1368
+ orphans: sumBytes(raw.filter(({ kind }) => kind === 'container')),
1369
+ images: sumBytes(raw.filter(({ kind }) => kind === 'image')),
1370
+ buildCache: sumBytes(raw.filter(({ kind }) => kind === 'build-cache' || kind === 'cache')),
1371
+ staleNetworks: sumBytes(classified.resources.filter(({ kind, ownership }) => kind === 'network' && ownership === 'stale-config')),
1372
+ };
1373
+ const reclaimable = {
1374
+ orphans: sumBytes(actionable.filter(({ kind }) => kind === 'container')),
1375
+ images: sumBytes(actionable.filter(({ kind }) => kind === 'image')),
1376
+ buildCache: sumBytes(actionable.filter(({ kind }) => kind === 'build-cache' || kind === 'cache')),
1377
+ staleNetworks: sumBytes(actionable.filter(({ kind, ownership }) => kind === 'network' && ownership === 'stale-config')),
1378
+ };
1379
+ if ([...Object.values(observed), ...Object.values(reclaimable)].some((value) => value === undefined)) {
1380
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reclaim byte accounting exceeds the safe integer range', 'teardown-accounting');
1381
+ }
1382
+ const targetIds = continued?.targetIds ?? coldReclaimTargetIds ?? freshTargetIds;
1383
+ const removedTargetIds = coldTargetsMatch ? coldCompletedTargetIds : continued === undefined
1384
+ ? []
1385
+ : continuedAfterFailure ? continued.removedTargetIds
1386
+ : [...continued.removedTargetIds, continued.completedTargetId];
1387
+ const targetIdentities = continued?.targetIdentities ?? (coldTargetsMatch ? coldIdentities : freshTargetIdentities);
1388
+ if (targetIdentities === undefined) {
1389
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown target identities could not be retained exactly', 'teardown-targets');
1390
+ }
1391
+ const continuationFor = (completedTargetId) => ({
1392
+ purpose: 'down', digest: discoveryDigest, completedTargetId, runtimeDigest, targetIds, removedTargetIds, targetIdentities,
1393
+ freshAfter: new Date().toISOString(), drain: null,
1394
+ });
1395
+ const action = absent ? {
1396
+ stackId: input.stackId, configSha256: input.configSha256, step: null,
1397
+ coordinationRecord: currentCoordination, continuation: continuationFor(coldRecovery.current.targetId),
1398
+ observedAt: discovery.observedAt,
1399
+ remainingTargets: async (next) => {
1400
+ const nextCaches = await dependencyCacheAuthorities(input.dependencyCaches);
1401
+ if (!nextCaches)
1402
+ return undefined;
1403
+ const cacheRows = nextCaches.filter(({ evidence }) => evidence.state === 'present').map(({ cacheId }) => ({
1404
+ id: cacheId, name: cacheId, kind: 'cache', labels: {}, referenced: false, bytes: null,
1405
+ }));
1406
+ const classified = classifyReclaim({ ...reclaimInput(input),
1407
+ discovery: { observedAt: next.observedAt, producer: next.producer, resources: [...next.resources, ...cacheRows] } });
1408
+ return classified.kind === 'complete' ? actionableReclaimResources(classified.resources)
1409
+ .map(({ kind, id }) => `${kind}:${id}`).sort(lexical) : undefined;
1410
+ },
1411
+ } : step === undefined || first === undefined ? null : {
1412
+ stackId: input.stackId, configSha256: input.configSha256,
1413
+ step,
1414
+ coordinationRecord: currentCoordination, continuation: continuationFor(`${first.kind}:${first.id}`),
1415
+ observedAt: discovery.observedAt,
1416
+ };
1417
+ return {
1418
+ kind: 'complete', action,
1419
+ remaining: step === undefined ? actionable.length : actionable.length - 1,
1420
+ noop: actionable.length === 0 && !absent, discoveryDigest,
1421
+ byteAccounting: {
1422
+ observed: observed, reclaimable: reclaimable,
1423
+ action: first === undefined || step === undefined ? null : { resourceId: first.id, observedBytes: first.bytes },
1424
+ actuallyReclaimed: null,
1425
+ },
1426
+ consent: input.expectedDiscoveryDigest === undefined ? { digest: discoveryDigest, observedAt: discovery.observedAt } : null,
1427
+ envelope: reclaimTargetEnvelope('down', input.stackId, input.configSha256, discoveryDigest, targetCommitments(coldTargetsMatch ? coldIdentities : targetIdentities)),
1428
+ };
1429
+ }
1430
+ catch (error) {
1431
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, `teardown input could not be inspected safely: ${errorCause(error)}`, 'teardown');
1432
+ }
1433
+ }
1434
+ function targetCommitment(identity) {
1435
+ const body = JSON.stringify(canonicalValue(identity));
1436
+ return {
1437
+ format: 'cockpit-reclaim-target/v1', targetId: identity.targetId,
1438
+ sha256: createHash('sha256').update(body).digest('hex'), body,
1439
+ };
1440
+ }
1441
+ function targetCommitments(identities) {
1442
+ return identities.map(targetCommitment);
1443
+ }
1444
+ function reclaimTargetEnvelope(purpose, stackId, configSha256, planDigest, commitments) {
1445
+ return {
1446
+ purpose, stackId, configSha256, planDigest,
1447
+ commitments: commitments.map((commitment) => ({ ...commitment })),
1448
+ };
1449
+ }
1450
+ function snapshotCommittedTargetIdentity(value) {
1451
+ const parsed = snapshotJson(value);
1452
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1453
+ return undefined;
1454
+ const candidate = parsed;
1455
+ const validLabels = (labels) => labels !== null && typeof labels === 'object'
1456
+ && !Array.isArray(labels) && Object.keys(labels).length <= 64
1457
+ && Object.entries(labels).every(([key, item]) => key.length > 0 && key.length <= 256 && !key.includes('\0')
1458
+ && typeof item === 'string' && item.length <= 512 && !item.includes('\0'));
1459
+ if (typeof candidate.targetId !== 'string' || candidate.targetId.length === 0
1460
+ || Buffer.byteLength(candidate.targetId, 'utf8') > 512)
1461
+ return undefined;
1462
+ if (candidate.medium === 'runtime' && candidate.scope === 'reset-volume') {
1463
+ if (!exactKeys(candidate, ['medium', 'scope', 'targetId', 'kind', 'declaration', 'runtimeId', 'name', 'labels', 'generation'])
1464
+ || candidate.kind !== 'volume' || typeof candidate.declaration !== 'string'
1465
+ || typeof candidate.runtimeId !== 'string' || typeof candidate.name !== 'string'
1466
+ || typeof candidate.generation !== 'string' || !validLabels(candidate.labels)
1467
+ || candidate.targetId !== `volume:${candidate.name}`
1468
+ || digestValue(canonicalLabels(candidate.labels)) !== digestValue(candidate.labels))
1469
+ return undefined;
1470
+ return candidate;
1471
+ }
1472
+ if (candidate.medium === 'runtime' && candidate.scope === 'teardown-runtime') {
1473
+ if (!exactKeys(candidate, ['medium', 'scope', 'targetId', 'kind', 'runtimeId', 'name', 'labels', 'referenced', 'dataBearing', 'generation'])
1474
+ || !['container', 'network', 'volume', 'image', 'build-cache'].includes(candidate.kind)
1475
+ || typeof candidate.runtimeId !== 'string' || typeof candidate.name !== 'string'
1476
+ || !validLabels(candidate.labels)
1477
+ || typeof candidate.referenced !== 'boolean'
1478
+ || candidate.dataBearing !== null && typeof candidate.dataBearing !== 'boolean'
1479
+ || candidate.generation !== null && typeof candidate.generation !== 'string'
1480
+ || candidate.targetId !== `${candidate.kind}:${candidate.runtimeId}`
1481
+ || digestValue(canonicalLabels(candidate.labels)) !== digestValue(candidate.labels))
1482
+ return undefined;
1483
+ return candidate;
1484
+ }
1485
+ if (candidate.medium === 'path') {
1486
+ const ancestors = candidate.ancestors;
1487
+ if (!exactKeys(candidate, ['medium', 'targetId', 'kind', 'declaration', 'root', 'path', 'type', 'dev', 'ino', 'birthtimeNs', 'ancestors'])
1488
+ || candidate.kind !== 'cache' && candidate.kind !== 'host-path'
1489
+ || typeof candidate.declaration !== 'string' || typeof candidate.root !== 'string' || typeof candidate.path !== 'string'
1490
+ || !isAbsolute(candidate.root) || normalize(candidate.root) !== candidate.root
1491
+ || !isAbsolute(candidate.path) || normalize(candidate.path) !== candidate.path
1492
+ || relative(candidate.root, candidate.path) === '' || relative(candidate.root, candidate.path) === '..'
1493
+ || relative(candidate.root, candidate.path).startsWith(`..${sep}`) || isAbsolute(relative(candidate.root, candidate.path))
1494
+ || candidate.type !== 'directory' && candidate.type !== 'file'
1495
+ || !isFilesystemObjectIdentity(candidate)
1496
+ || !Array.isArray(ancestors) || ancestors.length < 1 || ancestors.length > 64
1497
+ || new Set(ancestors.map((ancestor) => ancestor !== null && typeof ancestor === 'object'
1498
+ ? ancestor.path : undefined)).size !== ancestors.length
1499
+ || ancestors.some((ancestor, index) => ancestor === null || typeof ancestor !== 'object' || Array.isArray(ancestor)
1500
+ || !exactKeys(ancestor, ['path', 'dev', 'ino', 'birthtimeNs'])
1501
+ || typeof ancestor.path !== 'string' || !isAbsolute(ancestor.path) || normalize(ancestor.path) !== ancestor.path
1502
+ || !isFilesystemObjectIdentity(ancestor)
1503
+ || (index === 0 ? ancestor.path !== candidate.root : dirname(ancestor.path) !== ancestors[index - 1].path))
1504
+ || dirname(candidate.path) !== ancestors.at(-1).path)
1505
+ return undefined;
1506
+ return candidate;
1507
+ }
1508
+ return undefined;
1509
+ }
1510
+ function committedTargetIdentities(recovery) {
1511
+ try {
1512
+ const targets = [...recovery.completed, recovery.current, ...recovery.pending];
1513
+ const identities = targets.map((target) => {
1514
+ const commitment = target.targetCommitment;
1515
+ if (!commitment || createHash('sha256').update(commitment.body).digest('hex') !== commitment.sha256)
1516
+ throw new RefusalError('invalid commitment');
1517
+ const parsed = snapshotCommittedTargetIdentity(JSON.parse(commitment.body));
1518
+ if (!parsed || parsed.targetId !== target.targetId
1519
+ || commitment.body !== JSON.stringify(canonicalValue(parsed)))
1520
+ throw new RefusalError('invalid identity');
1521
+ return parsed;
1522
+ });
1523
+ return identities.length === targets.length ? identities : undefined;
1524
+ }
1525
+ catch {
1526
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1527
+ return undefined;
1528
+ }
1529
+ }
1530
+ function canonicalLabels(labels) {
1531
+ const required = new Set(Object.values(REQUIRED_LABELS));
1532
+ return Object.fromEntries(Object.entries(labels)
1533
+ .filter(([key]) => required.has(key))
1534
+ .sort(([left], [right]) => lexical(left, right)));
1535
+ }
1536
+ function teardownRuntimeTargetIdentity(targetId, resource) {
1537
+ if (resource.kind === 'cache')
1538
+ return undefined;
1539
+ return {
1540
+ medium: 'runtime', scope: 'teardown-runtime', targetId, kind: resource.kind,
1541
+ runtimeId: resource.id, name: resource.name, labels: canonicalLabels(resource.labels),
1542
+ referenced: resource.referenced, dataBearing: resource.dataBearing ?? null,
1543
+ generation: resource.generation ?? null,
1544
+ };
1545
+ }
1546
+ function pathTargetIdentity(targetId, kind, declaration, root, path, evidence) {
1547
+ if (evidence.state !== 'present' || evidence.type === undefined || evidence.dev === undefined || evidence.ino === undefined
1548
+ || evidence.birthtimeNs === undefined || evidence.ancestors === undefined)
1549
+ return undefined;
1550
+ return {
1551
+ medium: 'path', targetId, kind, declaration, root, path, type: evidence.type,
1552
+ dev: evidence.dev, ino: evidence.ino, birthtimeNs: evidence.birthtimeNs,
1553
+ ancestors: evidence.ancestors.map((ancestor) => ({ ...ancestor })),
1554
+ };
1555
+ }
1556
+ async function identityBoundFilesystemCheck(expected) {
1557
+ const relation = relative(expected.root, expected.path);
1558
+ if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`) || isAbsolute(relation))
1559
+ return undefined;
1560
+ const parts = relation.split(sep);
1561
+ const ancestorPaths = [expected.root];
1562
+ let cursor = expected.root;
1563
+ for (const segment of parts.slice(0, -1)) {
1564
+ cursor = resolve(cursor, segment);
1565
+ ancestorPaths.push(cursor);
1566
+ }
1567
+ try {
1568
+ if (ancestorPaths.length !== expected.ancestors.length)
1569
+ return undefined;
1570
+ const ancestors = [];
1571
+ for (const [index, path] of ancestorPaths.entries()) {
1572
+ const committed = expected.ancestors[index];
1573
+ if (committed === undefined || committed.path !== path)
1574
+ return undefined;
1575
+ const status = await lstat(path, { bigint: true });
1576
+ if (!status.isDirectory() || status.isSymbolicLink() || await realpath(path) !== path
1577
+ || !sameFilesystemObject(status, committed))
1578
+ return undefined;
1579
+ ancestors.push({ path, ...filesystemObjectIdentity(status) });
1580
+ }
1581
+ const target = await lstat(expected.path, { bigint: true });
1582
+ const type = target.isDirectory() ? 'directory' : target.isFile() ? 'file' : undefined;
1583
+ if (target.isSymbolicLink() || type !== expected.type || !sameFilesystemObject(target, expected))
1584
+ return undefined;
1585
+ return {
1586
+ path: expected.path,
1587
+ expected: expected.type,
1588
+ identity: { ...filesystemObjectIdentity(target), ancestors },
1589
+ };
1590
+ }
1591
+ catch {
1592
+ // A target that cannot be re-identified no longer matches; the caller refuses it.
1593
+ return undefined;
1594
+ }
1595
+ }
1596
+ function resetTargetIdentities(targetIds, targets) {
1597
+ const identities = targets.flatMap((target) => {
1598
+ const targetId = `${target.kind}:${target.identity}`;
1599
+ if (!targetIds.includes(targetId))
1600
+ return [];
1601
+ if (target.kind === 'host-path') {
1602
+ const identity = pathTargetIdentity(targetId, 'host-path', target.declaration, target.root, target.identity, target.evidence);
1603
+ return identity === undefined ? [] : [identity];
1604
+ }
1605
+ // A container is committed with the identity teardown commits for it, so recovery reads
1606
+ // one runtime identity shape whichever verb removed it.
1607
+ if (target.kind === 'container') {
1608
+ const identity = teardownRuntimeTargetIdentity(targetId, target.evidence.resource);
1609
+ return identity === undefined ? [] : [identity];
1610
+ }
1611
+ const evidence = target.evidence;
1612
+ return [{
1613
+ medium: 'runtime', scope: 'reset-volume',
1614
+ targetId, kind: 'volume', declaration: target.declaration,
1615
+ runtimeId: evidence.resourceId, name: target.identity,
1616
+ labels: canonicalLabels(target.labels ?? {}), generation: evidence.generation,
1617
+ }];
1618
+ }).sort((left, right) => lexical(left.targetId, right.targetId));
1619
+ return identities.length === targetIds.length
1620
+ && sameStrings(identities.map(({ targetId }) => targetId), targetIds)
1621
+ ? identities : undefined;
1622
+ }
1623
+ function teardownTargetIdentities(targetIds, resources, caches) {
1624
+ const identities = resources.flatMap((resource) => {
1625
+ const targetId = `${resource.kind}:${resource.id}`;
1626
+ if (!targetIds.includes(targetId))
1627
+ return [];
1628
+ if (resource.kind === 'cache') {
1629
+ const cache = caches.find(({ cacheId }) => cacheId === resource.id);
1630
+ if (cache === undefined)
1631
+ return [];
1632
+ const identity = pathTargetIdentity(targetId, 'cache', cache.cacheId, cache.root, cache.path, cache.evidence);
1633
+ return identity === undefined ? [] : [identity];
1634
+ }
1635
+ const identity = teardownRuntimeTargetIdentity(targetId, resource);
1636
+ return identity === undefined ? [] : [identity];
1637
+ }).sort((left, right) => lexical(left.targetId, right.targetId));
1638
+ return identities.length === targetIds.length
1639
+ && sameStrings(identities.map(({ targetId }) => targetId), targetIds)
1640
+ ? identities : undefined;
1641
+ }
1642
+ function sameStrings(left, right) {
1643
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1644
+ }
1645
+ function samePathTargetIdentity(expected) {
1646
+ try {
1647
+ for (const ancestor of expected.ancestors) {
1648
+ const current = lstatSync(ancestor.path, { bigint: true });
1649
+ if (!current.isDirectory() || current.isSymbolicLink() || !sameFilesystemObject(current, ancestor))
1650
+ return false;
1651
+ }
1652
+ const current = lstatSync(expected.path, { bigint: true });
1653
+ const type = current.isDirectory() ? 'directory' : current.isFile() ? 'file' : undefined;
1654
+ return type === expected.type && sameFilesystemObject(current, expected);
1655
+ }
1656
+ catch {
1657
+ // A path that cannot be re-identified no longer matches the recorded identity.
1658
+ return false;
1659
+ }
1660
+ }
1661
+ function pathIsAbsent(path) {
1662
+ try {
1663
+ lstatSync(path);
1664
+ return false;
1665
+ }
1666
+ catch (error) {
1667
+ return hasErrorCode(error, 'ENOENT');
1668
+ }
1669
+ }
1670
+ function selectedRuntimeResources(identity, resources) {
1671
+ return resources.filter((resource) => resource.kind === identity.kind
1672
+ && (identity.scope === 'teardown-runtime' ? resource.id === identity.runtimeId : resource.name === identity.name));
1673
+ }
1674
+ function sameTargetIdentity(identity, resources) {
1675
+ if (identity.medium === 'path')
1676
+ return samePathTargetIdentity(identity);
1677
+ const observed = selectedRuntimeResources(identity, resources);
1678
+ if (observed.length !== 1)
1679
+ return false;
1680
+ const [resource] = observed;
1681
+ const common = resource.id === identity.runtimeId
1682
+ && resource.name === identity.name
1683
+ && digestValue(canonicalLabels(resource.labels)) === digestValue(identity.labels)
1684
+ && (resource.generation ?? null) === identity.generation;
1685
+ return identity.scope === 'reset-volume' ? common
1686
+ : common && resource.referenced === identity.referenced
1687
+ && (resource.dataBearing ?? null) === identity.dataBearing;
1688
+ }
1689
+ function sameTargetProjections(observed, original, targetIds) {
1690
+ if (observed.length !== targetIds.length)
1691
+ return false;
1692
+ return targetIds.every((targetId) => {
1693
+ const current = observed.filter((identity) => identity.targetId === targetId);
1694
+ const retained = original.filter((identity) => identity.targetId === targetId);
1695
+ return current.length === 1 && retained.length === 1 && digestValue(current[0]) === digestValue(retained[0]);
1696
+ });
1697
+ }
1698
+ function targetIsAbsent(identity, resources) {
1699
+ if (identity.medium === 'path')
1700
+ return pathIsAbsent(identity.path);
1701
+ return identity.scope === 'reset-volume'
1702
+ ? resources.every((resource) => resource.id !== identity.runtimeId
1703
+ && (resource.kind !== 'volume' || resource.name !== identity.name))
1704
+ : resources.every((resource) => resource.id !== identity.runtimeId
1705
+ && (resource.kind !== identity.kind || resource.name !== identity.name));
1706
+ }
1707
+ function reclaimCompletionFact(reservation, attempt, observedAt, remainingTargetIds) {
1708
+ return {
1709
+ purpose: reservation.purpose,
1710
+ stackId: reservation.stackId,
1711
+ configSha256: reservation.configSha256,
1712
+ planDigest: reservation.planDigest,
1713
+ operationBasis: reservation.operationBasis,
1714
+ coordinationRevision: reservation.coordinationRevision,
1715
+ reservationId: reservation.reservationId,
1716
+ reservedAt: reservation.reservedAt,
1717
+ generation: reservation.generation,
1718
+ attemptId: attempt.attemptId,
1719
+ attemptRevision: attempt.committedRevision,
1720
+ attemptCommittedAt: attempt.committedAt,
1721
+ validThrough: attempt.validThrough,
1722
+ actionIndex: attempt.actionIndex,
1723
+ currentTarget: attempt.currentTarget,
1724
+ currentStage: attempt.currentStage,
1725
+ observedAt: observedAt,
1726
+ remainingTargetIds: [...remainingTargetIds],
1727
+ };
1728
+ }
1729
+ /**
1730
+ * Runs the planned removal under the reserved attempt: the attempt must name exactly this target
1731
+ * of the committed target set, and the step runs fenced to the attempt's validity window.
1732
+ */
1733
+ export async function executeReclaimAction(input) {
1734
+ const { action } = input;
1735
+ const { continuation } = action;
1736
+ const candidate = input.attempt;
1737
+ if (candidate.reservation.attempt?.attemptId !== candidate.attempt.attemptId || candidate.attempt.status !== 'active' || candidate.attempt.currentStage !== 'reclaim')
1738
+ return { kind: 'unavailable' };
1739
+ const targetId = continuation.completedTargetId;
1740
+ const reclaimTargets = candidate.reservation.actions.filter(({ stage }) => stage === 'reclaim').map(({ targetId: id }) => id);
1741
+ const reserved = candidate.reservation.actions[candidate.attempt.actionIndex];
1742
+ if (candidate.reservation.purpose !== continuation.purpose
1743
+ || candidate.reservation.stackId !== action.stackId
1744
+ || candidate.reservation.configSha256 !== action.configSha256
1745
+ || candidate.reservation.planDigest !== continuation.digest
1746
+ || candidate.attempt.reservationId !== candidate.reservation.reservationId
1747
+ || candidate.attempt.purpose !== candidate.reservation.purpose
1748
+ || candidate.attempt.generation !== candidate.reservation.generation
1749
+ || candidate.attempt.actionIndex !== continuation.removedTargetIds.length + 1
1750
+ || reserved?.stage !== 'reclaim' || reserved.targetId !== targetId
1751
+ || candidate.attempt.currentTarget !== targetId
1752
+ || !sameStrings(reclaimTargets, continuation.targetIds))
1753
+ return { kind: 'unavailable' };
1754
+ const drain = reclaimDrainStep({ planningRecord: action.coordinationRecord, reservation: candidate.reservation });
1755
+ if (!drain)
1756
+ return { kind: 'unavailable' };
1757
+ const executedWith = (receipt, fence) => ({
1758
+ kind: 'executed',
1759
+ executed: { action, reservation: candidate.reservation, attempt: candidate.attempt, drain, receipt, fence },
1760
+ });
1761
+ if (action.step === null)
1762
+ return executedWith(null, null);
1763
+ const pathIdentity = continuation.targetIdentities.find((identity) => identity.targetId === targetId && identity.medium === 'path');
1764
+ const identityCheck = pathIdentity === undefined ? undefined : await identityBoundFilesystemCheck(pathIdentity);
1765
+ if (pathIdentity !== undefined && identityCheck === undefined)
1766
+ return { kind: 'unavailable' };
1767
+ const validThroughMs = Date.parse(candidate.attempt.validThrough);
1768
+ const remainingMs = validThroughMs - Date.now();
1769
+ if (!Number.isSafeInteger(validThroughMs) || remainingMs < 1)
1770
+ return { kind: 'unavailable' };
1771
+ const { fence } = candidate;
1772
+ const step = snapshotExecutionStep({
1773
+ ...action.step,
1774
+ timeoutMs: Math.min(action.step.timeoutMs, remainingMs),
1775
+ spawnNotAfter: candidate.attempt.validThrough,
1776
+ filesystemChecks: identityCheck === undefined ? action.step.filesystemChecks : [identityCheck],
1777
+ executionFence: fence,
1778
+ });
1779
+ if (!step)
1780
+ return { kind: 'unavailable' };
1781
+ const outcome = await input.execute(step);
1782
+ if (outcome.kind === 'refusal') {
1783
+ // Only a removal that provably never started leaves the target as it was.
1784
+ if (outcome.receipt !== undefined || outcome.execution.started || outcome.execution.signal !== null
1785
+ || outcome.execution.exitCode === 0)
1786
+ return { kind: 'unavailable' };
1787
+ const failedAt = new Date().toISOString();
1788
+ return {
1789
+ kind: 'not-started', code: outcome.code, fence,
1790
+ outcome: { kind: 'failure', reservation: candidate.reservation, failedAt },
1791
+ continuation: { ...continuation, drain: { ...drain, failedAt } },
1792
+ };
1793
+ }
1794
+ if (outcome.stderr.byteLength !== 0)
1795
+ return { kind: 'unavailable' };
1796
+ return executedWith(outcome.receipt, fence);
1797
+ }
1798
+ /**
1799
+ * Settles an executed removal from a discovery read after it: every completed target must be
1800
+ * absent and every remaining committed target unchanged, or the attempt stays for repair.
1801
+ */
1802
+ export async function reconcileReclaimAction(input) {
1803
+ const { action, reservation, attempt, drain, receipt } = input.executed;
1804
+ const { discovery } = input;
1805
+ const { continuation } = action;
1806
+ const remainder = reservation.actions.slice(attempt.actionIndex + 1)
1807
+ .filter(({ stage }) => stage === 'reclaim').map(({ targetId }) => targetId);
1808
+ const completed = new Set([...continuation.removedTargetIds, continuation.completedTargetId]);
1809
+ // An already-absent target ran nothing, so the discovery must follow the one that planned it.
1810
+ const freshAfter = receipt === null ? action.observedAt : receipt.finishedAt;
1811
+ const remaining = action.remainingTargets === undefined ? remainder : await action.remainingTargets(discovery);
1812
+ if (discovery.stackId !== reservation.stackId
1813
+ || receipt !== null && (Date.parse(receipt.startedAt) < Date.parse(attempt.committedAt)
1814
+ || Date.parse(receipt.startedAt) > Date.parse(receipt.finishedAt)
1815
+ || Date.parse(receipt.finishedAt) > Date.parse(attempt.validThrough)
1816
+ || Date.parse(receipt.finishedAt) > Date.parse(discovery.startedAt))
1817
+ || receipt === null && (Date.parse(discovery.startedAt) <= Date.parse(action.observedAt)
1818
+ || Date.parse(discovery.startedAt) < Date.parse(attempt.committedAt))
1819
+ || Date.parse(discovery.startedAt) > Date.parse(discovery.observedAt)
1820
+ || Date.parse(discovery.observedAt) > Date.parse(attempt.validThrough)
1821
+ || Date.parse(discovery.observedAt) > Date.now()
1822
+ || !remaining || !sameStrings(remaining, remainder)
1823
+ || !sameStrings([...completed, ...remainder].sort(lexical), continuation.targetIds)
1824
+ || continuation.targetIdentities.length !== continuation.targetIds.length
1825
+ || !continuation.targetIdentities.every((identity) => completed.has(identity.targetId)
1826
+ ? targetIsAbsent(identity, discovery.resources)
1827
+ : remainder.includes(identity.targetId) && sameTargetIdentity(identity, discovery.resources))) {
1828
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'fresh discovery did not prove the exact reserved removal set', 'reclaim-reconciliation');
1829
+ }
1830
+ return {
1831
+ kind: 'complete',
1832
+ outcome: { kind: 'success', reservation, fact: reclaimCompletionFact(reservation, attempt, discovery.observedAt, remainder) },
1833
+ continuation: remainder.length === 0 ? null : { ...continuation, freshAfter, drain },
1834
+ };
1835
+ }