@b00dea/cockpit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +74 -4
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isEntrypointKind } from '../entrypoint-kinds.js';
|
|
4
|
+
import { EntrypointDerivative, STRUCTURED_INPUT_ENTRY_LIMIT } from '../schema.js';
|
|
5
|
+
import { addCoordinationHolder, coordinationStateRecord, decodeCoordinationRecord, isDrainActive, removeExactCoordinationHolder, releaseClaim, readRootPortInventory, } from './claims.js';
|
|
6
|
+
import { errorCause, RefusalError, ENVIRONMENT_CODES, OBSERVATION_PRODUCER_BYTE_LIMIT, OBSERVATION_REASON_BYTE_LIMIT, REFUSAL_CODES, isCanonicalTimestamp, snapshotEnvironmentObservation, } from './contract.js';
|
|
7
|
+
import { SERIALIZED_RECORD_BYTE_LIMIT, abortStateTransaction, authenticateStateNamespace, readState, snapshotStateNamespace, transactState, withStateRootPortLock, } from './state.js';
|
|
8
|
+
const OWNER = 'environment-port-coordination';
|
|
9
|
+
const issuedLeaseResults = new WeakMap();
|
|
10
|
+
const availableCompensations = new WeakSet();
|
|
11
|
+
const reservedCompensations = new WeakSet();
|
|
12
|
+
const issuedEntrypointResults = new WeakMap();
|
|
13
|
+
const issuedLeaseProjections = new WeakMap();
|
|
14
|
+
const issuedRetainedPortFamilies = new WeakMap();
|
|
15
|
+
const availableRetainedPortFamilies = new WeakSet();
|
|
16
|
+
function issueObservation(value) {
|
|
17
|
+
const snapshot = snapshotEnvironmentObservation(value);
|
|
18
|
+
if (!snapshot || snapshot.owner !== OWNER)
|
|
19
|
+
throw new RefusalError('port observation is malformed');
|
|
20
|
+
return snapshot;
|
|
21
|
+
}
|
|
22
|
+
export async function observePortFamily(input) {
|
|
23
|
+
const namespace = authenticateStateNamespace(snapshotStateNamespace(input.namespace));
|
|
24
|
+
if (!namespace || !/^stack:[a-f0-9]{64}$/.test(input.stackId) || !isCanonicalTimestamp(input.observedAt)) {
|
|
25
|
+
throw new RefusalError('port observation input is malformed');
|
|
26
|
+
}
|
|
27
|
+
const inventory = await readRootPortInventory(namespace);
|
|
28
|
+
const refusal = inventory.kind === 'refusal' ? inventory.diagnostics[0] : undefined;
|
|
29
|
+
const cockpitId = namespace.paths.coordination(input.stackId).cockpitId;
|
|
30
|
+
const record = inventory.kind === 'complete' ? inventory.value.find((item) => item.cockpitId === cockpitId && item.stackId === input.stackId)?.record : undefined;
|
|
31
|
+
const occupied = new Set();
|
|
32
|
+
let collision = false;
|
|
33
|
+
if (inventory.kind === 'complete') {
|
|
34
|
+
for (const item of inventory.value) {
|
|
35
|
+
if (!item.record.lease || item.record.lease.released)
|
|
36
|
+
continue;
|
|
37
|
+
for (const port of Object.values(item.record.lease.ports)) {
|
|
38
|
+
if (occupied.has(port))
|
|
39
|
+
collision = true;
|
|
40
|
+
occupied.add(port);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const leaseAuthority = input.leaseEvidence && issuedLeaseResults.get(input.leaseEvidence);
|
|
45
|
+
const listenerEvidenceCurrent = input.leaseEvidence?.kind === 'complete'
|
|
46
|
+
&& leaseAuthority?.result.lease.stackId === input.stackId && leaseAuthority.observedAt === input.observedAt
|
|
47
|
+
&& record?.lease !== null && record?.lease !== undefined && !record.lease.released
|
|
48
|
+
&& sameValues(record.lease.ports, input.leaseEvidence.lease.ports);
|
|
49
|
+
return issueObservation({
|
|
50
|
+
code: ENVIRONMENT_CODES.PORT_FAMILY_COLLISION,
|
|
51
|
+
owner: OWNER,
|
|
52
|
+
stackId: input.stackId,
|
|
53
|
+
source: namespace.paths.coordination(input.stackId).path,
|
|
54
|
+
observed: refusal
|
|
55
|
+
? { state: 'failed', reason: refusal.message, observedAt: input.observedAt }
|
|
56
|
+
: collision
|
|
57
|
+
? { state: 'available', value: false, observedAt: input.observedAt }
|
|
58
|
+
: listenerEvidenceCurrent
|
|
59
|
+
? { state: 'available', value: true, observedAt: input.observedAt }
|
|
60
|
+
: { state: 'unavailable', reason: record?.lease && !record.lease.released
|
|
61
|
+
? 'current host-listener evidence is unavailable'
|
|
62
|
+
: 'the stack has no active port-family lease', observedAt: input.observedAt },
|
|
63
|
+
expectation: { kind: 'equal', value: true },
|
|
64
|
+
applicability: { state: 'required', posture: 'environment' },
|
|
65
|
+
freshness: { state: 'current' },
|
|
66
|
+
remedy: 'lease the declared port family before starting the stack',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const stackIdentity = /^stack:[a-f0-9]{64}$/;
|
|
70
|
+
const holderIdentity = /^holder:[a-f0-9]{64}:session$/;
|
|
71
|
+
const portKey = /^[A-Z][A-Z0-9_]*$/;
|
|
72
|
+
const DerivativeInputSchema = z.object({
|
|
73
|
+
lease: z.object({
|
|
74
|
+
stackId: z.string(),
|
|
75
|
+
offset: z.number(),
|
|
76
|
+
ports: z.record(z.string(), z.number()).refine((ports) => Object.keys(ports).length <= STRUCTURED_INPUT_ENTRY_LIMIT),
|
|
77
|
+
}).strict().nullable(),
|
|
78
|
+
declarations: z.array(z.object({
|
|
79
|
+
name: z.string(),
|
|
80
|
+
kind: z.string(),
|
|
81
|
+
port: z.string(),
|
|
82
|
+
path: z.string(),
|
|
83
|
+
hostname: EntrypointDerivative.shape.hostname,
|
|
84
|
+
scheme: EntrypointDerivative.shape.scheme,
|
|
85
|
+
}).strict()).max(STRUCTURED_INPUT_ENTRY_LIMIT),
|
|
86
|
+
declaredValues: z.record(z.string(), z.string()).refine((values) => Object.keys(values).length <= STRUCTURED_INPUT_ENTRY_LIMIT).optional(),
|
|
87
|
+
}).strict();
|
|
88
|
+
function issueLeaseResult(result, observedAt, holderIds, namespace, record, replaced) {
|
|
89
|
+
const snapshot = {
|
|
90
|
+
...result,
|
|
91
|
+
lease: { ...result.lease, ports: { ...result.lease.ports } },
|
|
92
|
+
};
|
|
93
|
+
const address = { ...namespace.paths.coordination(result.lease.stackId) };
|
|
94
|
+
const compensation = result.change === 'acquired' && replaced
|
|
95
|
+
? {
|
|
96
|
+
address,
|
|
97
|
+
recordBody: JSON.stringify(record),
|
|
98
|
+
replaced,
|
|
99
|
+
}
|
|
100
|
+
: undefined;
|
|
101
|
+
issuedLeaseResults.set(snapshot, {
|
|
102
|
+
result: snapshot,
|
|
103
|
+
observedAt,
|
|
104
|
+
holderIds: Object.freeze([...holderIds].sort()),
|
|
105
|
+
address,
|
|
106
|
+
...(compensation ? { compensation } : {}),
|
|
107
|
+
});
|
|
108
|
+
if (compensation)
|
|
109
|
+
availableCompensations.add(snapshot);
|
|
110
|
+
return snapshot;
|
|
111
|
+
}
|
|
112
|
+
function issuePortLeaseProjection(input) {
|
|
113
|
+
const projection = {
|
|
114
|
+
lease: { ...input.lease, ports: { ...input.lease.ports } },
|
|
115
|
+
ownership: {
|
|
116
|
+
change: input.change,
|
|
117
|
+
holderId: input.holderId,
|
|
118
|
+
recordRevision: input.recordRevision,
|
|
119
|
+
holderCount: input.holderIds.length,
|
|
120
|
+
holderIds: [...input.holderIds],
|
|
121
|
+
},
|
|
122
|
+
entrypoints: input.entrypoints.map((entrypoint) => ({ ...entrypoint })),
|
|
123
|
+
};
|
|
124
|
+
issuedLeaseProjections.set(projection, Object.freeze({ available: true, address: input.address }));
|
|
125
|
+
return projection;
|
|
126
|
+
}
|
|
127
|
+
export function projectPortLeaseResult(input) {
|
|
128
|
+
try {
|
|
129
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
130
|
+
if (Reflect.ownKeys(descriptors).length !== 2 || !('value' in descriptors.leaseResult) || !('value' in descriptors.entrypointResult))
|
|
131
|
+
return undefined;
|
|
132
|
+
const leaseResult = descriptors.leaseResult.value;
|
|
133
|
+
const entrypointResult = descriptors.entrypointResult.value;
|
|
134
|
+
const leaseEvidence = leaseResult && typeof leaseResult === 'object' ? issuedLeaseResults.get(leaseResult) : undefined;
|
|
135
|
+
const entrypointEvidence = entrypointResult && typeof entrypointResult === 'object' ? issuedEntrypointResults.get(entrypointResult) : undefined;
|
|
136
|
+
if (!leaseEvidence || leaseResult.kind !== 'complete' || !entrypointEvidence || entrypointResult.kind !== 'complete'
|
|
137
|
+
|| leaseEvidence.result !== leaseResult || entrypointEvidence.stackId !== leaseEvidence.result.lease.stackId
|
|
138
|
+
|| !sameValues(entrypointEvidence.ports, leaseEvidence.result.lease.ports))
|
|
139
|
+
return undefined;
|
|
140
|
+
const issued = leaseEvidence.result;
|
|
141
|
+
return issuePortLeaseProjection({
|
|
142
|
+
lease: issued.lease,
|
|
143
|
+
change: issued.change,
|
|
144
|
+
holderId: issued.holderId,
|
|
145
|
+
recordRevision: issued.recordRevision,
|
|
146
|
+
holderIds: leaseEvidence.holderIds,
|
|
147
|
+
entrypoints: entrypointResult.values,
|
|
148
|
+
address: leaseEvidence.address,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// `config: 'recorded'` is for the commands that stop or remove a stack. The lease belongs to the
|
|
157
|
+
// stack, and its containers publish the ports it was started with, so editing [ports.bases] while it
|
|
158
|
+
// runs must not strand it: comparing against the edited declaration made sleep and down refuse the
|
|
159
|
+
// very stack the recovery tells the operator to remove.
|
|
160
|
+
export async function observeRetainedPortFamily(supplied) {
|
|
161
|
+
let namespace;
|
|
162
|
+
let stackId;
|
|
163
|
+
let holderId;
|
|
164
|
+
let checkoutId;
|
|
165
|
+
let config;
|
|
166
|
+
try {
|
|
167
|
+
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
|
|
168
|
+
throw new RefusalError('invalid input');
|
|
169
|
+
const descriptors = Object.getOwnPropertyDescriptors(supplied);
|
|
170
|
+
const keys = ['namespace', 'stackId', 'holderId', 'checkoutId', 'config'];
|
|
171
|
+
if (Reflect.ownKeys(descriptors).length !== keys.length
|
|
172
|
+
|| keys.some((key) => !descriptors[key] || !('value' in descriptors[key])))
|
|
173
|
+
throw new RefusalError('invalid input');
|
|
174
|
+
namespace = authenticateStateNamespace(descriptors.namespace.value);
|
|
175
|
+
stackId = descriptors.stackId.value;
|
|
176
|
+
holderId = descriptors.holderId.value;
|
|
177
|
+
checkoutId = descriptors.checkoutId.value;
|
|
178
|
+
const configSnapshot = descriptors.config.value === 'recorded' ? 'recorded' : snapshotPortConfig(descriptors.config.value);
|
|
179
|
+
if (typeof configSnapshot === 'string' && configSnapshot !== 'recorded')
|
|
180
|
+
throw new RefusalError(configSnapshot);
|
|
181
|
+
config = configSnapshot;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// A throwing accessor or proxy trap makes the input malformed; refused here.
|
|
185
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'retained port-family input is malformed', 'retained port family');
|
|
186
|
+
}
|
|
187
|
+
if (!namespace || !stackId || !holderId || !checkoutId || !config
|
|
188
|
+
|| !stackIdentity.test(stackId) || !holderIdentity.test(holderId)
|
|
189
|
+
|| !/^checkout:[a-f0-9]{64}$/.test(checkoutId)) {
|
|
190
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'retained port-family input is malformed', String(stackId ?? ''));
|
|
191
|
+
}
|
|
192
|
+
const address = namespace.paths.coordination(stackId);
|
|
193
|
+
const stored = await readState(address);
|
|
194
|
+
if (stored.kind !== 'complete') {
|
|
195
|
+
return stored.kind === 'missing'
|
|
196
|
+
? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'retained port-family observation found no coordination record', stored.path)
|
|
197
|
+
: stateRefusal(stored);
|
|
198
|
+
}
|
|
199
|
+
const value = decodeCoordinationRecord(stored.value, stackId);
|
|
200
|
+
if (!value)
|
|
201
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', address.path);
|
|
202
|
+
const holder = value.holders.find((candidate) => candidate.holderId === holderId);
|
|
203
|
+
if (!holder || holder.checkoutId !== checkoutId || value.lease === null || value.lease.released) {
|
|
204
|
+
return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'retained port-family observation requires the exact current holder and active lease', stackId);
|
|
205
|
+
}
|
|
206
|
+
if (config !== 'recorded' && !sameDeclaration(value.lease.declaration, config)) {
|
|
207
|
+
return refuse(ENVIRONMENT_CODES.CONFIG_CHANGED_DURING_EXECUTION, 'active port lease declaration changed during execution', stackId);
|
|
208
|
+
}
|
|
209
|
+
const inventory = await readRootPortInventory(namespace);
|
|
210
|
+
if (inventory.kind !== 'complete')
|
|
211
|
+
return inventory;
|
|
212
|
+
const cockpitId = address.cockpitId;
|
|
213
|
+
const ports = new Set(Object.values(value.lease.ports));
|
|
214
|
+
if (inventory.value.some((item) => (item.cockpitId !== cockpitId || item.stackId !== stackId)
|
|
215
|
+
&& item.record.lease !== null && !item.record.lease.released
|
|
216
|
+
&& Object.values(item.record.lease.ports).some((port) => ports.has(port)))) {
|
|
217
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, 'retained port family intersects another active state-root reservation', stackId, 'stop and release the conflicting stack through its owning cockpit before retrying');
|
|
218
|
+
}
|
|
219
|
+
const result = {
|
|
220
|
+
kind: 'complete',
|
|
221
|
+
holderId,
|
|
222
|
+
recordRevision: stored.value.revision,
|
|
223
|
+
lease: { stackId, offset: value.lease.offset, ports: { ...value.lease.ports } },
|
|
224
|
+
};
|
|
225
|
+
issuedRetainedPortFamilies.set(result, Object.freeze({
|
|
226
|
+
address: Object.freeze({ ...address }),
|
|
227
|
+
recordFingerprint: JSON.stringify(stored.value),
|
|
228
|
+
holderIds: Object.freeze(value.holders.map(({ holderId }) => holderId)),
|
|
229
|
+
}));
|
|
230
|
+
availableRetainedPortFamilies.add(result);
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
// Whether the stack still holds a lease taken under a different [ports] declaration. `up` asks
|
|
234
|
+
// before leasing so that editing the port family of a recorded stack meets the same down-then-up
|
|
235
|
+
// recovery as any other runtime change, instead of the lease's mid-execution refusal.
|
|
236
|
+
export async function inspectRecordedPortDeclaration(input) {
|
|
237
|
+
const address = input.namespace.paths.coordination(input.stackId);
|
|
238
|
+
const stored = await readState(address);
|
|
239
|
+
if (stored.kind === 'missing')
|
|
240
|
+
return { kind: 'complete', changed: false };
|
|
241
|
+
if (stored.kind !== 'complete')
|
|
242
|
+
return stateRefusal(stored);
|
|
243
|
+
const value = decodeCoordinationRecord(stored.value, input.stackId);
|
|
244
|
+
if (!value)
|
|
245
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', address.path);
|
|
246
|
+
const lease = value.lease;
|
|
247
|
+
return { kind: 'complete', changed: lease !== null && !lease.released && !sameDeclaration(lease.declaration, input.config) };
|
|
248
|
+
}
|
|
249
|
+
export async function projectRetainedPortFamilyResult(input) {
|
|
250
|
+
try {
|
|
251
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
252
|
+
if (Reflect.ownKeys(descriptors).length !== 2
|
|
253
|
+
|| !('value' in descriptors.retainedResult) || !('value' in descriptors.entrypointResult))
|
|
254
|
+
return undefined;
|
|
255
|
+
const retainedResult = descriptors.retainedResult.value;
|
|
256
|
+
const entrypointResult = descriptors.entrypointResult.value;
|
|
257
|
+
const retained = retainedResult && typeof retainedResult === 'object'
|
|
258
|
+
? issuedRetainedPortFamilies.get(retainedResult)
|
|
259
|
+
: undefined;
|
|
260
|
+
const entrypoints = entrypointResult && typeof entrypointResult === 'object'
|
|
261
|
+
? issuedEntrypointResults.get(entrypointResult)
|
|
262
|
+
: undefined;
|
|
263
|
+
if (!retained || retainedResult.kind !== 'complete'
|
|
264
|
+
|| !availableRetainedPortFamilies.has(retainedResult)
|
|
265
|
+
|| !entrypoints || entrypointResult.kind !== 'complete'
|
|
266
|
+
|| entrypoints.stackId !== retainedResult.lease.stackId
|
|
267
|
+
|| !sameValues(entrypoints.ports, retainedResult.lease.ports))
|
|
268
|
+
return undefined;
|
|
269
|
+
availableRetainedPortFamilies.delete(retainedResult);
|
|
270
|
+
const current = await readState(retained.address);
|
|
271
|
+
if (current.kind !== 'complete' || current.value.revision !== retainedResult.recordRevision
|
|
272
|
+
|| JSON.stringify(current.value) !== retained.recordFingerprint)
|
|
273
|
+
return undefined;
|
|
274
|
+
return issuePortLeaseProjection({
|
|
275
|
+
lease: retainedResult.lease,
|
|
276
|
+
change: 'unchanged',
|
|
277
|
+
holderId: retainedResult.holderId,
|
|
278
|
+
recordRevision: retainedResult.recordRevision,
|
|
279
|
+
holderIds: retained.holderIds,
|
|
280
|
+
entrypoints: entrypointResult.values,
|
|
281
|
+
address: retained.address,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export function consumePortLeaseProjection(projection) {
|
|
290
|
+
const issued = projection && typeof projection === 'object' ? issuedLeaseProjections.get(projection) : undefined;
|
|
291
|
+
if (!issued?.available)
|
|
292
|
+
return undefined;
|
|
293
|
+
issuedLeaseProjections.set(projection, Object.freeze({ ...issued, available: false }));
|
|
294
|
+
return projection;
|
|
295
|
+
}
|
|
296
|
+
export function inspectPortLeaseProjection(projection, namespace) {
|
|
297
|
+
const issued = projection && typeof projection === 'object' ? issuedLeaseProjections.get(projection) : undefined;
|
|
298
|
+
if (!issued)
|
|
299
|
+
return undefined;
|
|
300
|
+
if (namespace === undefined)
|
|
301
|
+
return issued.available ? projection : undefined;
|
|
302
|
+
// Lifecycle admission consumes the projection before data work checks its coordination origin.
|
|
303
|
+
const authenticated = authenticateStateNamespace(namespace);
|
|
304
|
+
return authenticated?.paths.coordination(projection.lease.stackId).path === issued.address.path ? projection : undefined;
|
|
305
|
+
}
|
|
306
|
+
export async function revalidatePortLeaseProjection(input) {
|
|
307
|
+
try {
|
|
308
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
309
|
+
return false;
|
|
310
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
311
|
+
if (Reflect.ownKeys(descriptors).length !== 2
|
|
312
|
+
|| !('value' in (descriptors.projection ?? {})) || !('value' in (descriptors.namespace ?? {})))
|
|
313
|
+
return false;
|
|
314
|
+
const projection = descriptors.projection.value;
|
|
315
|
+
const issued = projection && typeof projection === 'object' ? issuedLeaseProjections.get(projection) : undefined;
|
|
316
|
+
const namespace = authenticateStateNamespace(snapshotStateNamespace(descriptors.namespace.value));
|
|
317
|
+
if (!issued || !namespace)
|
|
318
|
+
return false;
|
|
319
|
+
const address = namespace.paths.coordination(projection.lease.stackId);
|
|
320
|
+
if (!sameCoordinationAddress(address, issued.address))
|
|
321
|
+
return false;
|
|
322
|
+
const current = await readState(address);
|
|
323
|
+
if (current.kind !== 'complete' || current.value.revision !== projection.ownership.recordRevision)
|
|
324
|
+
return false;
|
|
325
|
+
const value = decodeCoordinationRecord(current.value, projection.lease.stackId);
|
|
326
|
+
if (!value?.lease || value.lease.released || value.lease.offset !== projection.lease.offset
|
|
327
|
+
|| !sameValues(value.lease.ports, projection.lease.ports))
|
|
328
|
+
return false;
|
|
329
|
+
const holderIds = value.holders.map(({ holderId }) => holderId).sort();
|
|
330
|
+
return holderIds.includes(projection.ownership.holderId)
|
|
331
|
+
&& projection.ownership.holderCount === holderIds.length
|
|
332
|
+
&& JSON.stringify(projection.ownership.holderIds) === JSON.stringify(holderIds);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Reading the supplied projection can only fail on a throwing accessor or proxy trap, which is not revalidated evidence.
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
export async function inspectPortLeaseTransfer(input) {
|
|
340
|
+
let namespace;
|
|
341
|
+
let stackId;
|
|
342
|
+
let holderId;
|
|
343
|
+
let checkoutId;
|
|
344
|
+
let previousHolderId;
|
|
345
|
+
let projection;
|
|
346
|
+
try {
|
|
347
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
348
|
+
if (Reflect.ownKeys(descriptors).length !== 6
|
|
349
|
+
|| !['namespace', 'stackId', 'holderId', 'checkoutId', 'previousHolderId', 'projection']
|
|
350
|
+
.every((key) => descriptors[key] && 'value' in descriptors[key]))
|
|
351
|
+
throw new RefusalError('invalid input');
|
|
352
|
+
namespace = authenticateStateNamespace(descriptors.namespace.value);
|
|
353
|
+
stackId = descriptors.stackId.value;
|
|
354
|
+
holderId = descriptors.holderId.value;
|
|
355
|
+
checkoutId = descriptors.checkoutId.value;
|
|
356
|
+
previousHolderId = descriptors.previousHolderId.value;
|
|
357
|
+
const candidate = descriptors.projection.value;
|
|
358
|
+
projection = candidate && typeof candidate === 'object' && candidate.lease && candidate.ownership
|
|
359
|
+
? {
|
|
360
|
+
...candidate,
|
|
361
|
+
lease: { ...candidate.lease, ports: { ...candidate.lease.ports } },
|
|
362
|
+
ownership: { ...candidate.ownership, holderIds: [...candidate.ownership.holderIds] },
|
|
363
|
+
}
|
|
364
|
+
: undefined;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// A throwing accessor or proxy trap makes the input malformed; refused here.
|
|
368
|
+
return refuse(ENVIRONMENT_CODES.LIFECYCLE_EVIDENCE_INVALID, 'port lease transfer input is malformed', 'port lease transfer');
|
|
369
|
+
}
|
|
370
|
+
if (!namespace || !stackId || !holderId || !checkoutId || !previousHolderId || !projection
|
|
371
|
+
|| !stackIdentity.test(stackId) || !holderIdentity.test(holderId) || !holderIdentity.test(previousHolderId)
|
|
372
|
+
|| !/^checkout:[a-f0-9]{64}$/.test(checkoutId) || previousHolderId === holderId
|
|
373
|
+
|| projection.lease.stackId !== stackId || projection.ownership.holderId !== holderId
|
|
374
|
+
|| !Number.isSafeInteger(projection.lease.offset) || projection.lease.offset < 0
|
|
375
|
+
|| !Number.isSafeInteger(projection.ownership.recordRevision) || projection.ownership.recordRevision < 0
|
|
376
|
+
|| projection.ownership.holderCount !== projection.ownership.holderIds.length) {
|
|
377
|
+
return refuse(ENVIRONMENT_CODES.LIFECYCLE_EVIDENCE_INVALID, 'port lease transfer input is malformed', String(stackId ?? 'port lease transfer'));
|
|
378
|
+
}
|
|
379
|
+
const stored = await readState(namespace.paths.coordination(stackId));
|
|
380
|
+
if (stored.kind !== 'complete') {
|
|
381
|
+
return stored.kind === 'missing'
|
|
382
|
+
? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'port lease transfer found no coordination record', stored.path)
|
|
383
|
+
: stateRefusal(stored);
|
|
384
|
+
}
|
|
385
|
+
const current = decodeCoordinationRecord(stored.value, stackId);
|
|
386
|
+
if (!current)
|
|
387
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', stored.value.subjectId);
|
|
388
|
+
const holder = current.holders.find((candidate) => candidate.holderId === holderId);
|
|
389
|
+
const currentHolderIds = current.holders.map((candidate) => candidate.holderId).sort();
|
|
390
|
+
const projectedHolderIds = [...projection.ownership.holderIds].sort();
|
|
391
|
+
if (stored.value.revision !== projection.ownership.recordRevision
|
|
392
|
+
|| !holder || holder.checkoutId !== checkoutId
|
|
393
|
+
|| current.holders.some((candidate) => candidate.holderId === previousHolderId)
|
|
394
|
+
|| currentHolderIds.length !== projectedHolderIds.length
|
|
395
|
+
|| currentHolderIds.some((candidate, index) => candidate !== projectedHolderIds[index])
|
|
396
|
+
|| current.lease === null || current.lease.released || current.lease.offset !== projection.lease.offset
|
|
397
|
+
|| !sameValues(current.lease.ports, projection.lease.ports)) {
|
|
398
|
+
return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'lifecycle progress belongs to an active or replaced holder', stackId);
|
|
399
|
+
}
|
|
400
|
+
return { kind: 'complete' };
|
|
401
|
+
}
|
|
402
|
+
function diagnostic(severity, code, message, target, fix) {
|
|
403
|
+
return { severity, code, message, target, ...(fix ? { fix } : {}) };
|
|
404
|
+
}
|
|
405
|
+
function refuse(code, message, target, fix) {
|
|
406
|
+
return { kind: 'refusal', diagnostics: [diagnostic('error', code, message, target, fix)] };
|
|
407
|
+
}
|
|
408
|
+
function stateRefusal(result) {
|
|
409
|
+
return refuse(result.code, result.message, result.target, 'recovery' in result ? result.recovery : undefined);
|
|
410
|
+
}
|
|
411
|
+
function sameValues(left, right) {
|
|
412
|
+
const keys = Object.keys(left);
|
|
413
|
+
return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
|
|
414
|
+
}
|
|
415
|
+
function sameDeclaration(recorded, declared) {
|
|
416
|
+
return recorded.step === declared.step && recorded.count === declared.count && sameValues(recorded.bases, declared.bases);
|
|
417
|
+
}
|
|
418
|
+
function validateConfig(config) {
|
|
419
|
+
if (!config
|
|
420
|
+
|| !Number.isSafeInteger(config.step)
|
|
421
|
+
|| config.step < 1
|
|
422
|
+
|| !Number.isSafeInteger(config.count)
|
|
423
|
+
|| config.count < 1
|
|
424
|
+
|| !config.bases
|
|
425
|
+
|| Object.keys(config.bases).length === 0)
|
|
426
|
+
return 'port family count, step, and bases must be positive safe integers';
|
|
427
|
+
const memberCount = config.count * Object.keys(config.bases).length;
|
|
428
|
+
if (!Number.isSafeInteger(memberCount) || memberCount > STRUCTURED_INPUT_ENTRY_LIMIT) {
|
|
429
|
+
return `port family candidate members must not exceed the shared input structure budget of ${STRUCTURED_INPUT_ENTRY_LIMIT}`;
|
|
430
|
+
}
|
|
431
|
+
for (const [key, base] of Object.entries(config.bases)) {
|
|
432
|
+
if (!portKey.test(key) || !Number.isSafeInteger(base) || base < 1 || base > 65535) {
|
|
433
|
+
return `port family base ${key} is outside the supported range`;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (new Set(Object.values(config.bases)).size !== Object.keys(config.bases).length) {
|
|
437
|
+
return 'port family bases must not overlap';
|
|
438
|
+
}
|
|
439
|
+
const familyMembers = new Set();
|
|
440
|
+
for (let index = 0; index < config.count; index += 1) {
|
|
441
|
+
const offset = index * config.step;
|
|
442
|
+
for (const base of Object.values(config.bases)) {
|
|
443
|
+
const port = base + offset;
|
|
444
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
445
|
+
return 'configured port family exceeds the safe host port range';
|
|
446
|
+
}
|
|
447
|
+
if (familyMembers.has(port))
|
|
448
|
+
return 'declared candidate port families overlap';
|
|
449
|
+
familyMembers.add(port);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
function snapshotPortConfig(value) {
|
|
455
|
+
try {
|
|
456
|
+
if (!value || typeof value !== 'object')
|
|
457
|
+
return 'port family config is invalid';
|
|
458
|
+
const step = value.step;
|
|
459
|
+
const count = value.count;
|
|
460
|
+
const rawBases = value.bases;
|
|
461
|
+
if (!rawBases || typeof rawBases !== 'object' || Array.isArray(rawBases))
|
|
462
|
+
return 'port family bases are invalid';
|
|
463
|
+
const keys = Object.keys(rawBases);
|
|
464
|
+
if (keys.length < 1 || keys.length > STRUCTURED_INPUT_ENTRY_LIMIT)
|
|
465
|
+
return 'port family bases exceed the shared input structure budget';
|
|
466
|
+
const bases = {};
|
|
467
|
+
let byteBudget = 2;
|
|
468
|
+
for (const key of keys) {
|
|
469
|
+
const base = rawBases[key];
|
|
470
|
+
byteBudget += Buffer.byteLength(key, 'utf8') + 16;
|
|
471
|
+
if (byteBudget > SERIALIZED_RECORD_BYTE_LIMIT)
|
|
472
|
+
return 'port family bases exceed the state record byte budget';
|
|
473
|
+
bases[key] = base;
|
|
474
|
+
}
|
|
475
|
+
const snapshot = { step: step, count: count, bases };
|
|
476
|
+
return validateConfig(snapshot) ?? snapshot;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// A throwing accessor or proxy trap makes the config malformed; the reason says so.
|
|
480
|
+
return 'port family config is invalid';
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function transactLease(namespace, stackId, holderId, update) {
|
|
484
|
+
const result = await transactState(namespace, namespace.paths.coordination(stackId), { ownerId: holderId, contentionTimeoutMs: 2_000 }, update);
|
|
485
|
+
const aborted = result;
|
|
486
|
+
return aborted.aborted === true ? aborted.value : result;
|
|
487
|
+
}
|
|
488
|
+
function hasExactKeys(value, keys) {
|
|
489
|
+
const actual = Object.keys(value).sort();
|
|
490
|
+
const expected = [...keys].sort();
|
|
491
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
492
|
+
}
|
|
493
|
+
function boundedArrayLength(value, maximum) {
|
|
494
|
+
try {
|
|
495
|
+
if (!Array.isArray(value))
|
|
496
|
+
return undefined;
|
|
497
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, 'length');
|
|
498
|
+
if (!descriptor
|
|
499
|
+
|| !('value' in descriptor)
|
|
500
|
+
|| !Number.isSafeInteger(descriptor.value)
|
|
501
|
+
|| descriptor.value < 0
|
|
502
|
+
|| descriptor.value > maximum)
|
|
503
|
+
return undefined;
|
|
504
|
+
return descriptor.value;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// A throwing accessor or proxy trap makes the value unreadable, which the caller refuses.
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function boundedText(value, maximumBytes) {
|
|
512
|
+
return typeof value === 'string'
|
|
513
|
+
&& value.length > 0
|
|
514
|
+
&& Buffer.byteLength(value, 'utf8') <= maximumBytes;
|
|
515
|
+
}
|
|
516
|
+
function snapshotListenerEvidence(value, config) {
|
|
517
|
+
const entryCount = boundedArrayLength(value, config.count);
|
|
518
|
+
if (entryCount === undefined)
|
|
519
|
+
return undefined;
|
|
520
|
+
const snapshots = [];
|
|
521
|
+
let byteBudget = 2;
|
|
522
|
+
try {
|
|
523
|
+
for (let entryIndex = 0; entryIndex < entryCount; entryIndex += 1) {
|
|
524
|
+
if (!Object.hasOwn(value, entryIndex))
|
|
525
|
+
return undefined;
|
|
526
|
+
const rawEntry = value[entryIndex];
|
|
527
|
+
if (!rawEntry || typeof rawEntry !== 'object' || !hasExactKeys(rawEntry, ['ports', 'observation']))
|
|
528
|
+
return undefined;
|
|
529
|
+
const rawPorts = rawEntry.ports;
|
|
530
|
+
const rawObservation = rawEntry.observation;
|
|
531
|
+
const portCount = boundedArrayLength(rawPorts, Object.keys(config.bases).length);
|
|
532
|
+
if (portCount === undefined || portCount !== Object.keys(config.bases).length)
|
|
533
|
+
return undefined;
|
|
534
|
+
const ports = [];
|
|
535
|
+
for (let portIndex = 0; portIndex < portCount; portIndex += 1) {
|
|
536
|
+
if (!Object.hasOwn(rawPorts, portIndex))
|
|
537
|
+
return undefined;
|
|
538
|
+
const rawPort = rawPorts[portIndex];
|
|
539
|
+
if (!Number.isSafeInteger(rawPort) || rawPort < 1 || rawPort > 65535)
|
|
540
|
+
return undefined;
|
|
541
|
+
ports.push(rawPort);
|
|
542
|
+
byteBudget += 8;
|
|
543
|
+
}
|
|
544
|
+
if (new Set(ports).size !== ports.length || !rawObservation || typeof rawObservation !== 'object')
|
|
545
|
+
return undefined;
|
|
546
|
+
const state = rawObservation.state;
|
|
547
|
+
let observation;
|
|
548
|
+
if (state === 'available') {
|
|
549
|
+
if (!hasExactKeys(rawObservation, ['state', 'value', 'observedAt', 'producer']))
|
|
550
|
+
return undefined;
|
|
551
|
+
const rawOccupied = rawObservation.value;
|
|
552
|
+
const occupiedCount = boundedArrayLength(rawOccupied, ports.length);
|
|
553
|
+
const observedAt = rawObservation.observedAt;
|
|
554
|
+
const producer = rawObservation.producer;
|
|
555
|
+
if (occupiedCount === undefined || typeof observedAt !== 'string' || !boundedText(producer, OBSERVATION_PRODUCER_BYTE_LIMIT))
|
|
556
|
+
return undefined;
|
|
557
|
+
const occupied = [];
|
|
558
|
+
for (let occupiedIndex = 0; occupiedIndex < occupiedCount; occupiedIndex += 1) {
|
|
559
|
+
if (!Object.hasOwn(rawOccupied, occupiedIndex))
|
|
560
|
+
return undefined;
|
|
561
|
+
const rawPort = rawOccupied[occupiedIndex];
|
|
562
|
+
if (!Number.isSafeInteger(rawPort))
|
|
563
|
+
return undefined;
|
|
564
|
+
occupied.push(rawPort);
|
|
565
|
+
byteBudget += 8;
|
|
566
|
+
}
|
|
567
|
+
byteBudget += Buffer.byteLength(observedAt, 'utf8') + Buffer.byteLength(producer, 'utf8');
|
|
568
|
+
observation = { state, value: occupied, observedAt, producer };
|
|
569
|
+
}
|
|
570
|
+
else if (state === 'unavailable') {
|
|
571
|
+
if (!hasExactKeys(rawObservation, ['state', 'reason', 'producer']))
|
|
572
|
+
return undefined;
|
|
573
|
+
const reason = rawObservation.reason;
|
|
574
|
+
const producer = rawObservation.producer;
|
|
575
|
+
if (!boundedText(reason, OBSERVATION_REASON_BYTE_LIMIT) || !boundedText(producer, OBSERVATION_PRODUCER_BYTE_LIMIT))
|
|
576
|
+
return undefined;
|
|
577
|
+
byteBudget += Buffer.byteLength(reason, 'utf8') + Buffer.byteLength(producer, 'utf8');
|
|
578
|
+
observation = { state, reason, producer };
|
|
579
|
+
}
|
|
580
|
+
else if (state === 'failed') {
|
|
581
|
+
if (!hasExactKeys(rawObservation, ['state', 'code', 'reason', 'producer']))
|
|
582
|
+
return undefined;
|
|
583
|
+
const code = rawObservation.code;
|
|
584
|
+
const reason = rawObservation.reason;
|
|
585
|
+
const producer = rawObservation.producer;
|
|
586
|
+
if (!REFUSAL_CODES.includes(code)
|
|
587
|
+
|| !boundedText(reason, OBSERVATION_REASON_BYTE_LIMIT)
|
|
588
|
+
|| !boundedText(producer, OBSERVATION_PRODUCER_BYTE_LIMIT))
|
|
589
|
+
return undefined;
|
|
590
|
+
byteBudget += Buffer.byteLength(reason, 'utf8') + Buffer.byteLength(producer, 'utf8');
|
|
591
|
+
observation = { state, code: code, reason, producer };
|
|
592
|
+
}
|
|
593
|
+
else
|
|
594
|
+
return undefined;
|
|
595
|
+
if (byteBudget > SERIALIZED_RECORD_BYTE_LIMIT)
|
|
596
|
+
return undefined;
|
|
597
|
+
snapshots.push({ ports, observation });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// A throwing accessor or proxy trap makes the evidence malformed, which the caller refuses.
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
return snapshots;
|
|
605
|
+
}
|
|
606
|
+
async function probeLoopbackPort(port, timeoutMs, host = '127.0.0.1') {
|
|
607
|
+
return new Promise((resolveProbe) => {
|
|
608
|
+
const socket = createConnection({ host, port });
|
|
609
|
+
let settled = false;
|
|
610
|
+
const finish = (value) => {
|
|
611
|
+
if (settled)
|
|
612
|
+
return;
|
|
613
|
+
settled = true;
|
|
614
|
+
socket.destroy();
|
|
615
|
+
resolveProbe(value);
|
|
616
|
+
};
|
|
617
|
+
socket.setTimeout(timeoutMs, () => finish(undefined));
|
|
618
|
+
socket.once('connect', () => finish(true));
|
|
619
|
+
socket.once('error', (error) => finish(error.code === 'ECONNREFUSED' ? false : undefined));
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async function probeListenerFamily(ports, timeoutMs) {
|
|
623
|
+
const probes = await Promise.all(ports.map(async (port) => [port, await probeLoopbackPort(port, timeoutMs)]));
|
|
624
|
+
if (probes.some(([, occupied]) => occupied === undefined))
|
|
625
|
+
return undefined;
|
|
626
|
+
return probes.filter(([, occupied]) => occupied).map(([port]) => port);
|
|
627
|
+
}
|
|
628
|
+
export async function observePortListeners(input) {
|
|
629
|
+
try {
|
|
630
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
631
|
+
return { kind: 'unavailable', reason: 'listener observation input is malformed' };
|
|
632
|
+
}
|
|
633
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
634
|
+
if (Reflect.ownKeys(descriptors).length !== 2
|
|
635
|
+
|| !('value' in (descriptors.bindings ?? {})) || !('value' in (descriptors.timeoutMs ?? {}))) {
|
|
636
|
+
return { kind: 'unavailable', reason: 'listener observation input is malformed' };
|
|
637
|
+
}
|
|
638
|
+
const bindings = descriptors.bindings.value;
|
|
639
|
+
const timeoutMs = descriptors.timeoutMs.value;
|
|
640
|
+
if (!Array.isArray(bindings) || bindings.length === 0 || bindings.length > STRUCTURED_INPUT_ENTRY_LIMIT
|
|
641
|
+
|| !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) {
|
|
642
|
+
return { kind: 'unavailable', reason: 'listener observation input is outside its declared bounds' };
|
|
643
|
+
}
|
|
644
|
+
const rows = [];
|
|
645
|
+
const identities = new Set();
|
|
646
|
+
for (const binding of bindings) {
|
|
647
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) {
|
|
648
|
+
return { kind: 'unavailable', reason: 'listener binding is malformed' };
|
|
649
|
+
}
|
|
650
|
+
const captured = Object.getOwnPropertyDescriptors(binding);
|
|
651
|
+
if (Reflect.ownKeys(captured).length !== 3
|
|
652
|
+
|| !('value' in (captured.hostIp ?? {})) || !('value' in (captured.port ?? {}))
|
|
653
|
+
|| !('value' in (captured.expected ?? {}))) {
|
|
654
|
+
return { kind: 'unavailable', reason: 'listener binding is malformed' };
|
|
655
|
+
}
|
|
656
|
+
const hostIp = captured.hostIp.value;
|
|
657
|
+
const port = captured.port.value;
|
|
658
|
+
const expected = captured.expected.value;
|
|
659
|
+
if (!['127.0.0.1', '::1', '0.0.0.0', '::'].includes(hostIp)
|
|
660
|
+
|| !Number.isSafeInteger(port) || port < 1 || port > 65_535
|
|
661
|
+
|| expected !== 'listening' && expected !== 'free') {
|
|
662
|
+
return { kind: 'unavailable', reason: 'listener binding is outside its declared bounds' };
|
|
663
|
+
}
|
|
664
|
+
const probeHost = hostIp === '::1' || hostIp === '::' ? '::1' : '127.0.0.1';
|
|
665
|
+
const identity = `${probeHost}\0${port}`;
|
|
666
|
+
if (identities.has(identity)) {
|
|
667
|
+
return { kind: 'unavailable', reason: 'listener observation contains a duplicate address' };
|
|
668
|
+
}
|
|
669
|
+
identities.add(identity);
|
|
670
|
+
rows.push({ hostIp: hostIp, probeHost, port, expected });
|
|
671
|
+
}
|
|
672
|
+
const probed = await Promise.all(rows.map(async (row) => ({
|
|
673
|
+
hostIp: row.hostIp,
|
|
674
|
+
probeHost: row.probeHost,
|
|
675
|
+
port: row.port,
|
|
676
|
+
listening: await probeLoopbackPort(row.port, timeoutMs, row.probeHost),
|
|
677
|
+
})));
|
|
678
|
+
if (probed.some(({ listening }) => listening === undefined)) {
|
|
679
|
+
return { kind: 'unavailable', reason: 'bounded host listener probe did not produce current evidence' };
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
kind: 'complete',
|
|
683
|
+
rows: probed.map((row) => ({ ...row, listening: row.listening })),
|
|
684
|
+
observedAt: new Date().toISOString(),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
return {
|
|
689
|
+
kind: 'unavailable',
|
|
690
|
+
reason: `listener observation failed: ${errorCause(error)}`,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function observePortFamilies(input) {
|
|
695
|
+
const declaredCandidates = [];
|
|
696
|
+
for (let index = 0; index < input.config.count; index += 1) {
|
|
697
|
+
const offset = index * input.config.step;
|
|
698
|
+
const ports = Object.fromEntries(Object.entries(input.config.bases).map(([key, base]) => [key, base + offset]));
|
|
699
|
+
declaredCandidates.push({ offset, ports });
|
|
700
|
+
}
|
|
701
|
+
const selectedOffsets = input.offsets;
|
|
702
|
+
const candidates = selectedOffsets
|
|
703
|
+
? declaredCandidates.filter(({ offset }) => selectedOffsets.has(offset))
|
|
704
|
+
: declaredCandidates;
|
|
705
|
+
const supplied = new Map();
|
|
706
|
+
for (const entry of input.supplied) {
|
|
707
|
+
if (!entry
|
|
708
|
+
|| !hasExactKeys(entry, ['ports', 'observation'])
|
|
709
|
+
|| !Array.isArray(entry.ports)
|
|
710
|
+
|| entry.ports.length === 0
|
|
711
|
+
|| entry.ports.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
712
|
+
|| new Set(entry.ports).size !== entry.ports.length) {
|
|
713
|
+
return { evidence: [], refusal: refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, 'host listener evidence does not cover the requested families exactly once', '') };
|
|
714
|
+
}
|
|
715
|
+
const key = [...entry.ports].sort((left, right) => left - right).join(',');
|
|
716
|
+
if (supplied.has(key)) {
|
|
717
|
+
return { evidence: [], refusal: refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, 'host listener evidence does not cover the requested families exactly once', key) };
|
|
718
|
+
}
|
|
719
|
+
supplied.set(key, entry.observation);
|
|
720
|
+
}
|
|
721
|
+
const candidateKeys = candidates.map(({ ports }) => Object.values(ports).sort((left, right) => left - right).join(','));
|
|
722
|
+
const declaredKeys = declaredCandidates.map(({ ports }) => Object.values(ports).sort((left, right) => left - right).join(','));
|
|
723
|
+
const selectedExact = supplied.size === candidateKeys.length && candidateKeys.every((key) => supplied.has(key));
|
|
724
|
+
const declaredExact = supplied.size === declaredKeys.length && declaredKeys.every((key) => supplied.has(key));
|
|
725
|
+
if (candidateKeys.length === 0 || !selectedExact && !declaredExact) {
|
|
726
|
+
return { evidence: [], refusal: refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, 'host listener evidence does not cover the requested families exactly once', '') };
|
|
727
|
+
}
|
|
728
|
+
const evidence = [];
|
|
729
|
+
for (const candidate of candidates) {
|
|
730
|
+
const target = Object.values(candidate.ports).join(',');
|
|
731
|
+
const malformed = () => ({
|
|
732
|
+
...candidate,
|
|
733
|
+
result: {
|
|
734
|
+
kind: 'refusal',
|
|
735
|
+
refusal: refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, 'host listener evidence is malformed', target, 'restore the named listener probe and retry'),
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
const observation = supplied.get([...Object.values(candidate.ports)].sort((left, right) => left - right).join(','));
|
|
739
|
+
if (observation === null || typeof observation !== 'object') {
|
|
740
|
+
evidence.push(malformed());
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (observation.state === 'unavailable') {
|
|
744
|
+
if (!hasExactKeys(observation, ['state', 'reason', 'producer'])
|
|
745
|
+
|| typeof observation.reason !== 'string'
|
|
746
|
+
|| observation.reason.length === 0
|
|
747
|
+
|| typeof observation.producer !== 'string'
|
|
748
|
+
|| observation.producer.length === 0) {
|
|
749
|
+
evidence.push(malformed());
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
evidence.push({
|
|
753
|
+
...candidate,
|
|
754
|
+
result: { kind: 'refusal', refusal: refuse(ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, 'host listener availability could not be established', target, 'restore the named listener probe and retry') },
|
|
755
|
+
});
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (observation.state === 'failed') {
|
|
759
|
+
if (!hasExactKeys(observation, ['state', 'code', 'reason', 'producer'])
|
|
760
|
+
|| !REFUSAL_CODES.includes(observation.code)
|
|
761
|
+
|| typeof observation.reason !== 'string'
|
|
762
|
+
|| observation.reason.length === 0
|
|
763
|
+
|| typeof observation.producer !== 'string'
|
|
764
|
+
|| observation.producer.length === 0) {
|
|
765
|
+
evidence.push(malformed());
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
evidence.push({
|
|
769
|
+
...candidate,
|
|
770
|
+
result: { kind: 'refusal', refusal: refuse(ENVIRONMENT_CODES.PORT_FAMILY_COLLISION, 'host listener observation failed', target, 'restore the named listener probe and retry') },
|
|
771
|
+
});
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const ports = Object.values(candidate.ports);
|
|
775
|
+
const observedAt = isCanonicalTimestamp(observation.observedAt)
|
|
776
|
+
? Date.parse(observation.observedAt)
|
|
777
|
+
: Number.NaN;
|
|
778
|
+
const requestedAt = Date.parse(input.requestedAt);
|
|
779
|
+
if (observation.state !== 'available'
|
|
780
|
+
|| !hasExactKeys(observation, ['state', 'value', 'observedAt', 'producer'])
|
|
781
|
+
|| !Array.isArray(observation.value)
|
|
782
|
+
|| new Set(observation.value).size !== observation.value.length
|
|
783
|
+
|| !Number.isFinite(observedAt)
|
|
784
|
+
|| observedAt < requestedAt
|
|
785
|
+
|| observedAt > requestedAt + input.timeoutMs
|
|
786
|
+
|| typeof observation.producer !== 'string'
|
|
787
|
+
|| observation.producer.length === 0
|
|
788
|
+
|| observation.value.some((port) => !Number.isSafeInteger(port) || !ports.includes(port))) {
|
|
789
|
+
evidence.push(malformed());
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
evidence.push({
|
|
793
|
+
...candidate,
|
|
794
|
+
result: {
|
|
795
|
+
kind: 'complete',
|
|
796
|
+
occupied: [...observation.value],
|
|
797
|
+
observedAt: observation.observedAt,
|
|
798
|
+
producer: observation.producer,
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return { evidence };
|
|
803
|
+
}
|
|
804
|
+
export async function leasePortFamily(supplied) {
|
|
805
|
+
let input;
|
|
806
|
+
try {
|
|
807
|
+
const namespaceProperty = Object.getOwnPropertyDescriptor(supplied, 'namespace');
|
|
808
|
+
if (!namespaceProperty || !('value' in namespaceProperty))
|
|
809
|
+
throw new RefusalError('invalid namespace property');
|
|
810
|
+
const namespace = authenticateStateNamespace(snapshotStateNamespace(namespaceProperty.value));
|
|
811
|
+
if (!namespace)
|
|
812
|
+
throw new RefusalError('invalid namespace');
|
|
813
|
+
const stackId = supplied.stackId;
|
|
814
|
+
const holderId = supplied.holderId;
|
|
815
|
+
const checkoutId = supplied.checkoutId;
|
|
816
|
+
const config = snapshotPortConfig(supplied.config);
|
|
817
|
+
const now = supplied.now;
|
|
818
|
+
const listenerTimeoutMs = supplied.listenerTimeoutMs;
|
|
819
|
+
const holderProcess = supplied.holderProcess === undefined ? undefined : {
|
|
820
|
+
...(supplied.holderProcess.pid === undefined ? {} : { pid: supplied.holderProcess.pid }),
|
|
821
|
+
...(supplied.holderProcess.processStart === undefined ? {} : { processStart: supplied.holderProcess.processStart }),
|
|
822
|
+
};
|
|
823
|
+
if (typeof config === 'string') {
|
|
824
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, config, String(stackId ?? ''));
|
|
825
|
+
}
|
|
826
|
+
const snapshot = {
|
|
827
|
+
namespace,
|
|
828
|
+
stackId,
|
|
829
|
+
holderId,
|
|
830
|
+
checkoutId,
|
|
831
|
+
config,
|
|
832
|
+
now,
|
|
833
|
+
listenerTimeoutMs,
|
|
834
|
+
...(holderProcess === undefined ? {} : { holderProcess }),
|
|
835
|
+
};
|
|
836
|
+
const suppliedEvidence = supplied.listenerEvidence;
|
|
837
|
+
const listenerEvidence = snapshotListenerEvidence(suppliedEvidence, config);
|
|
838
|
+
if (!listenerEvidence) {
|
|
839
|
+
return refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, `host listener evidence must contain no more than ${snapshot.config.count} bounded candidate families within the state record byte budget`, String(snapshot.stackId ?? ''));
|
|
840
|
+
}
|
|
841
|
+
input = { ...snapshot, listenerEvidence };
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
// A throwing accessor or proxy trap makes the input invalid; refused here.
|
|
845
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'port lease input is invalid', '');
|
|
846
|
+
}
|
|
847
|
+
const invalid = validateConfig(input?.config);
|
|
848
|
+
if (invalid
|
|
849
|
+
|| !stackIdentity.test(input?.stackId)
|
|
850
|
+
|| !holderIdentity.test(input?.holderId)
|
|
851
|
+
|| !/^checkout:[a-f0-9]{64}$/.test(input?.checkoutId)
|
|
852
|
+
|| !isCanonicalTimestamp(input?.now)
|
|
853
|
+
|| !Number.isSafeInteger(input?.listenerTimeoutMs)
|
|
854
|
+
|| input.listenerTimeoutMs < 1
|
|
855
|
+
|| input.listenerTimeoutMs > 30_000
|
|
856
|
+
|| !Array.isArray(input?.listenerEvidence)) {
|
|
857
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, invalid ?? 'port lease input is invalid', String(input?.stackId ?? ''));
|
|
858
|
+
}
|
|
859
|
+
const priorResult = await readState(input.namespace.paths.coordination(input.stackId));
|
|
860
|
+
if (priorResult.kind !== 'complete' && priorResult.kind !== 'missing')
|
|
861
|
+
return stateRefusal(priorResult);
|
|
862
|
+
const prior = decodeCoordinationRecord(priorResult.kind === 'complete' ? priorResult.value : null, input.stackId);
|
|
863
|
+
if (!prior)
|
|
864
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', input.stackId);
|
|
865
|
+
if (isDrainActive(prior))
|
|
866
|
+
return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'port leasing is blocked while the stack is draining', input.stackId);
|
|
867
|
+
const observed = observePortFamilies({
|
|
868
|
+
config: input.config,
|
|
869
|
+
requestedAt: input.now,
|
|
870
|
+
timeoutMs: input.listenerTimeoutMs,
|
|
871
|
+
supplied: input.listenerEvidence,
|
|
872
|
+
...(prior.lease && !prior.lease.released ? { offsets: new Set([prior.lease.offset]) } : {}),
|
|
873
|
+
});
|
|
874
|
+
if (observed.refusal)
|
|
875
|
+
return observed.refusal;
|
|
876
|
+
let replaced;
|
|
877
|
+
const allocateFamily = () => transactLease(input.namespace, input.stackId, input.holderId, async (current) => {
|
|
878
|
+
const currentValue = decodeCoordinationRecord(current, input.stackId);
|
|
879
|
+
if (!currentValue)
|
|
880
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', input.stackId));
|
|
881
|
+
if (isDrainActive(currentValue))
|
|
882
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'port leasing is blocked while the stack is draining', input.stackId));
|
|
883
|
+
const added = addCoordinationHolder(currentValue, { holderId: input.holderId, checkoutId: input.checkoutId, ...input.holderProcess }, input.now);
|
|
884
|
+
if (!added)
|
|
885
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination holder set cannot accept the requested holder', input.holderId));
|
|
886
|
+
const currentLease = currentValue.lease;
|
|
887
|
+
const inventoryResult = await readRootPortInventory(input.namespace);
|
|
888
|
+
if (inventoryResult.kind !== 'complete')
|
|
889
|
+
return abortStateTransaction(inventoryResult);
|
|
890
|
+
const cockpitId = input.namespace.paths.coordination(input.stackId).cockpitId;
|
|
891
|
+
const active = inventoryResult.value
|
|
892
|
+
.filter((item) => item.cockpitId !== cockpitId || item.stackId !== input.stackId)
|
|
893
|
+
.flatMap((item) => item.record.lease !== null && !item.record.lease.released
|
|
894
|
+
? [{ cockpitId: item.cockpitId, lease: item.record.lease }] : []);
|
|
895
|
+
if ((!currentLease || currentLease.released) && active.filter((item) => item.cockpitId === cockpitId).length >= input.config.count) {
|
|
896
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.PORT_LEASE_EXHAUSTED, `maximum ${input.config.count} concurrent stacks is already active`, input.stackId, 'release an inactive stack or extend the declared port-family count after checking host capacity'));
|
|
897
|
+
}
|
|
898
|
+
if (currentLease && !currentLease.released) {
|
|
899
|
+
if (!sameDeclaration(currentLease.declaration, input.config)) {
|
|
900
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.CONFIG_CHANGED_DURING_EXECUTION, 'active port lease declaration changed during execution', input.stackId, 'release the active stack before changing its port family declaration'));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const occupied = new Set(active.flatMap(({ lease }) => Object.values(lease.ports)));
|
|
904
|
+
// While a holder retains the lease, its ports are published by the stack's own runtime, so a
|
|
905
|
+
// listener there is the stack itself. Probing them refused every repeated `up` and every
|
|
906
|
+
// co-tenant joining a running stack with PORT_FAMILY_COLLISION on its own ports. A lease no
|
|
907
|
+
// holder retains belongs to a stopped stack, and a listener there is someone else's.
|
|
908
|
+
const retained = currentLease !== null && !currentLease.released && currentValue.holders.length > 0;
|
|
909
|
+
let sawListenerCollision = false;
|
|
910
|
+
const occupiedFamilies = [];
|
|
911
|
+
for (const evidence of observed.evidence) {
|
|
912
|
+
const { offset, ports: portMap } = evidence;
|
|
913
|
+
const ports = Object.values(portMap);
|
|
914
|
+
if (currentLease && !currentLease.released) {
|
|
915
|
+
if (offset !== currentLease.offset || !sameValues(portMap, currentLease.ports))
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
if (ports.some((port) => occupied.has(port))) {
|
|
919
|
+
occupiedFamilies.push(ports.join(','));
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (evidence.result.kind === 'refusal')
|
|
923
|
+
return abortStateTransaction(evidence.result.refusal);
|
|
924
|
+
const loopbackOccupied = retained ? [] : await probeListenerFamily(ports, input.listenerTimeoutMs);
|
|
925
|
+
if (!loopbackOccupied)
|
|
926
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.PORT_LISTENER_EVIDENCE_INVALID, 'bounded host listener probe did not produce current evidence', input.stackId, 'restore loopback listener access and retry'));
|
|
927
|
+
if (!retained && (evidence.result.occupied.length > 0 || loopbackOccupied.length > 0)) {
|
|
928
|
+
sawListenerCollision = true;
|
|
929
|
+
occupiedFamilies.push(ports.join(','));
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
const value = {
|
|
933
|
+
offset,
|
|
934
|
+
ports: portMap,
|
|
935
|
+
released: false,
|
|
936
|
+
declaration: { step: input.config.step, count: input.config.count, bases: { ...input.config.bases } },
|
|
937
|
+
};
|
|
938
|
+
const complete = {
|
|
939
|
+
kind: 'complete',
|
|
940
|
+
change: added.change,
|
|
941
|
+
holderId: input.holderId,
|
|
942
|
+
recordRevision: current?.revision ?? 0,
|
|
943
|
+
lease: { stackId: input.stackId, offset, ports: value.ports },
|
|
944
|
+
observation: 'resolved-listener-evidence-accepted',
|
|
945
|
+
};
|
|
946
|
+
if (currentLease && !currentLease.released && added.change === 'unchanged') {
|
|
947
|
+
return abortStateTransaction({
|
|
948
|
+
leaseResult: complete,
|
|
949
|
+
holderIds: added.value.holders.map(({ holderId }) => holderId).sort(),
|
|
950
|
+
record: current,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
replaced = currentValue;
|
|
954
|
+
return coordinationStateRecord(input.namespace, input.stackId, current, input.now, { ...added.value, lease: value });
|
|
955
|
+
}
|
|
956
|
+
return abortStateTransaction(refuse(sawListenerCollision ? ENVIRONMENT_CODES.PORT_FAMILY_COLLISION : ENVIRONMENT_CODES.PORT_LEASE_EXHAUSTED, `no complete port family is free; occupied candidates: ${occupiedFamilies.join('; ') || 'all declared candidates'}`, input.stackId, 'release an inactive stack, stop the named host listener, or extend the declared candidate range'));
|
|
957
|
+
});
|
|
958
|
+
const locked = await withStateRootPortLock(input.namespace, { ownerId: input.holderId, contentionTimeoutMs: 2_000 }, allocateFamily);
|
|
959
|
+
if (locked.kind === 'missing')
|
|
960
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'root port transaction returned no result', locked.path);
|
|
961
|
+
if (locked.kind !== 'complete')
|
|
962
|
+
return stateRefusal(locked);
|
|
963
|
+
const transaction = locked.value;
|
|
964
|
+
if ('leaseResult' in transaction)
|
|
965
|
+
return issueLeaseResult(transaction.leaseResult, input.now, transaction.holderIds, input.namespace, transaction.record);
|
|
966
|
+
if ('diagnostics' in transaction)
|
|
967
|
+
return transaction;
|
|
968
|
+
if (transaction.kind === 'missing')
|
|
969
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'lease transaction returned no record', transaction.path);
|
|
970
|
+
if (transaction.kind !== 'complete')
|
|
971
|
+
return stateRefusal(transaction);
|
|
972
|
+
const value = decodeCoordinationRecord(transaction.value, input.stackId);
|
|
973
|
+
if (!value?.lease)
|
|
974
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'published coordination record is malformed', transaction.value.subjectId);
|
|
975
|
+
return issueLeaseResult({ kind: 'complete', change: 'acquired', holderId: input.holderId, recordRevision: transaction.value.revision, lease: { stackId: input.stackId, offset: value.lease.offset, ports: value.lease.ports }, observation: 'resolved-listener-evidence-accepted' }, input.now, value.holders.map(({ holderId }) => holderId), input.namespace, transaction.value, replaced);
|
|
976
|
+
}
|
|
977
|
+
function sameCoordinationAddress(left, right) {
|
|
978
|
+
return left.path === right.path && left.stateRoot === right.stateRoot && left.cockpitRoot === right.cockpitRoot
|
|
979
|
+
&& left.kind === right.kind && left.cockpitId === right.cockpitId && left.subjectId === right.subjectId
|
|
980
|
+
&& left.qualifier === undefined && right.qualifier === undefined;
|
|
981
|
+
}
|
|
982
|
+
export async function compensateAcquiredPortHolder(input) {
|
|
983
|
+
let namespace;
|
|
984
|
+
let suppliedLeaseResult;
|
|
985
|
+
let now;
|
|
986
|
+
try {
|
|
987
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
988
|
+
throw new RefusalError('invalid input');
|
|
989
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
990
|
+
if (Reflect.ownKeys(descriptors).length !== 3
|
|
991
|
+
|| !['namespace', 'leaseResult', 'now'].every((key) => descriptors[key] && 'value' in descriptors[key])) {
|
|
992
|
+
throw new RefusalError('invalid input');
|
|
993
|
+
}
|
|
994
|
+
namespace = authenticateStateNamespace(descriptors.namespace.value);
|
|
995
|
+
suppliedLeaseResult = descriptors.leaseResult.value;
|
|
996
|
+
now = descriptors.now.value;
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// A throwing accessor or proxy trap makes the input malformed; refused here.
|
|
1000
|
+
return refuse(ENVIRONMENT_CODES.LIFECYCLE_EVIDENCE_INVALID, 'acquired port-holder compensation input is malformed', 'port-holder compensation');
|
|
1001
|
+
}
|
|
1002
|
+
const issuance = suppliedLeaseResult !== null && typeof suppliedLeaseResult === 'object'
|
|
1003
|
+
? issuedLeaseResults.get(suppliedLeaseResult)
|
|
1004
|
+
: undefined;
|
|
1005
|
+
const compensation = issuance?.compensation;
|
|
1006
|
+
if (!namespace || !issuance || issuance.result !== suppliedLeaseResult || issuance.result.change !== 'acquired'
|
|
1007
|
+
|| !compensation || !availableCompensations.has(issuance.result)
|
|
1008
|
+
|| reservedCompensations.has(issuance.result) || !isCanonicalTimestamp(now)) {
|
|
1009
|
+
return refuse(ENVIRONMENT_CODES.LIFECYCLE_EVIDENCE_INVALID, 'an exact unused acquired lease result is required for compensation', 'port-holder compensation');
|
|
1010
|
+
}
|
|
1011
|
+
const leaseResult = issuance.result;
|
|
1012
|
+
const address = namespace.paths.coordination(leaseResult.lease.stackId);
|
|
1013
|
+
if (!sameCoordinationAddress(address, compensation.address)) {
|
|
1014
|
+
return refuse(ENVIRONMENT_CODES.LIFECYCLE_EVIDENCE_INVALID, 'acquired lease authority belongs to another state namespace', leaseResult.lease.stackId);
|
|
1015
|
+
}
|
|
1016
|
+
reservedCompensations.add(leaseResult);
|
|
1017
|
+
const preserveAuthority = () => reservedCompensations.delete(leaseResult);
|
|
1018
|
+
const consumeAuthority = () => {
|
|
1019
|
+
reservedCompensations.delete(leaseResult);
|
|
1020
|
+
availableCompensations.delete(leaseResult);
|
|
1021
|
+
};
|
|
1022
|
+
const transaction = await transactLease(namespace, leaseResult.lease.stackId, leaseResult.holderId, (current) => {
|
|
1023
|
+
const value = decodeCoordinationRecord(current, leaseResult.lease.stackId);
|
|
1024
|
+
if (!current || !value || current.revision !== leaseResult.recordRevision
|
|
1025
|
+
|| JSON.stringify(current) !== compensation.recordBody) {
|
|
1026
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'acquired holder compensation requires the exact current coordination record', leaseResult.lease.stackId));
|
|
1027
|
+
}
|
|
1028
|
+
if (!removeExactCoordinationHolder(value, leaseResult.holderId)) {
|
|
1029
|
+
return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'acquired holder compensation requires the exact current holder', leaseResult.holderId));
|
|
1030
|
+
}
|
|
1031
|
+
// The record is exactly what the acquisition wrote, so the value it replaced is the exact
|
|
1032
|
+
// inverse. Dropping only the holder left a lease no holder retains, which kept the family
|
|
1033
|
+
// reserved and made the next up after a [ports] edit refuse a stack nothing was running.
|
|
1034
|
+
return coordinationStateRecord(namespace, leaseResult.lease.stackId, current, now, compensation.replaced);
|
|
1035
|
+
});
|
|
1036
|
+
if ('diagnostics' in transaction) {
|
|
1037
|
+
preserveAuthority();
|
|
1038
|
+
return transaction;
|
|
1039
|
+
}
|
|
1040
|
+
if (transaction.kind === 'missing') {
|
|
1041
|
+
preserveAuthority();
|
|
1042
|
+
return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'acquired holder coordination record is no longer current', leaseResult.lease.stackId);
|
|
1043
|
+
}
|
|
1044
|
+
if (transaction.kind !== 'complete') {
|
|
1045
|
+
if (transaction.kind === 'indeterminate')
|
|
1046
|
+
consumeAuthority();
|
|
1047
|
+
else
|
|
1048
|
+
preserveAuthority();
|
|
1049
|
+
return stateRefusal(transaction);
|
|
1050
|
+
}
|
|
1051
|
+
consumeAuthority();
|
|
1052
|
+
const value = decodeCoordinationRecord(transaction.value, leaseResult.lease.stackId);
|
|
1053
|
+
if (!value) {
|
|
1054
|
+
return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'published coordination record is malformed', leaseResult.lease.stackId);
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
kind: 'complete',
|
|
1058
|
+
change: 'compensated',
|
|
1059
|
+
recordRevision: transaction.value.revision,
|
|
1060
|
+
remainingHolderIds: value.holders.map(({ holderId }) => holderId),
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Lease the port family and project its entrypoints as one step. A caller only ever receives a
|
|
1065
|
+
* lease together with its projection, so when derivation or projection fails after this call added
|
|
1066
|
+
* a holder, nobody downstream could release it: give exactly that holder back here.
|
|
1067
|
+
*/
|
|
1068
|
+
export async function acquirePortProjection(input) {
|
|
1069
|
+
const { declarations, ...lease } = input;
|
|
1070
|
+
const leaseResult = await leasePortFamily(lease);
|
|
1071
|
+
if (leaseResult.kind !== 'complete')
|
|
1072
|
+
return leaseResult;
|
|
1073
|
+
const giveBack = async () => {
|
|
1074
|
+
if (leaseResult.change !== 'acquired')
|
|
1075
|
+
return [];
|
|
1076
|
+
const released = await compensateAcquiredPortHolder({ namespace: lease.namespace, leaseResult, now: new Date().toISOString() });
|
|
1077
|
+
return released.kind === 'complete' ? [] : released.diagnostics;
|
|
1078
|
+
};
|
|
1079
|
+
const entrypoints = deriveEntrypoints({ lease: leaseResult.lease, declarations });
|
|
1080
|
+
if (entrypoints.kind !== 'complete') {
|
|
1081
|
+
return { kind: 'refusal', diagnostics: [...entrypoints.diagnostics, ...await giveBack()] };
|
|
1082
|
+
}
|
|
1083
|
+
const projection = projectPortLeaseResult({ leaseResult, entrypointResult: entrypoints });
|
|
1084
|
+
if (!projection) {
|
|
1085
|
+
const residue = await giveBack();
|
|
1086
|
+
return residue.length > 0 ? { kind: 'refusal', diagnostics: residue } : { kind: 'unavailable' };
|
|
1087
|
+
}
|
|
1088
|
+
return { kind: 'complete', leaseResult, projection };
|
|
1089
|
+
}
|
|
1090
|
+
export async function releasePortFamily(input) {
|
|
1091
|
+
let request;
|
|
1092
|
+
try {
|
|
1093
|
+
const namespaceProperty = Object.getOwnPropertyDescriptor(input, 'namespace');
|
|
1094
|
+
if (!namespaceProperty || !('value' in namespaceProperty))
|
|
1095
|
+
throw new RefusalError('invalid namespace property');
|
|
1096
|
+
const namespace = snapshotStateNamespace(namespaceProperty.value);
|
|
1097
|
+
if (!namespace)
|
|
1098
|
+
throw new RefusalError('invalid namespace');
|
|
1099
|
+
request = {
|
|
1100
|
+
namespace,
|
|
1101
|
+
stackId: input.stackId,
|
|
1102
|
+
requesterId: input.requesterId,
|
|
1103
|
+
holderId: input.holderId,
|
|
1104
|
+
now: input.now,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
// A throwing accessor or proxy trap makes the input unreadable; refused here.
|
|
1109
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'port lease release input could not be read safely', 'port lease release input');
|
|
1110
|
+
}
|
|
1111
|
+
if (!stackIdentity.test(request.stackId)
|
|
1112
|
+
|| !holderIdentity.test(request.requesterId)
|
|
1113
|
+
|| !holderIdentity.test(request.holderId)
|
|
1114
|
+
|| !isCanonicalTimestamp(request.now)) {
|
|
1115
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'port lease release input is invalid', String(request.stackId ?? ''));
|
|
1116
|
+
}
|
|
1117
|
+
const result = await releaseClaim(request);
|
|
1118
|
+
if (result.kind === 'refusal')
|
|
1119
|
+
return result;
|
|
1120
|
+
return {
|
|
1121
|
+
kind: 'complete',
|
|
1122
|
+
change: result.change === 'unchanged' ? 'unchanged' : result.decision === 'stop' ? 'released' : 'retained',
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
function isLoopback(value) {
|
|
1126
|
+
try {
|
|
1127
|
+
const hostname = new URL(value).hostname;
|
|
1128
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
1129
|
+
|| EntrypointDerivative.shape.hostname.safeParse(hostname).success;
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
// An unparsable URL is not a loopback address.
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function snapshotDerivativeInput(value) {
|
|
1137
|
+
try {
|
|
1138
|
+
const parsed = DerivativeInputSchema.safeParse(value);
|
|
1139
|
+
if (!parsed.success)
|
|
1140
|
+
return undefined;
|
|
1141
|
+
const lease = parsed.data.lease === null ? null : {
|
|
1142
|
+
stackId: parsed.data.lease.stackId,
|
|
1143
|
+
offset: parsed.data.lease.offset,
|
|
1144
|
+
ports: parsed.data.lease.ports,
|
|
1145
|
+
};
|
|
1146
|
+
const declarations = parsed.data.declarations.map((entry) => ({
|
|
1147
|
+
...entry,
|
|
1148
|
+
kind: entry.kind,
|
|
1149
|
+
}));
|
|
1150
|
+
const declaredValues = parsed.data.declaredValues === undefined
|
|
1151
|
+
? undefined
|
|
1152
|
+
: parsed.data.declaredValues;
|
|
1153
|
+
return { lease, declarations, ...(declaredValues ? { declaredValues } : {}) };
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
// A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
export function deriveEntrypoints(input) {
|
|
1161
|
+
const snapshot = snapshotDerivativeInput(input);
|
|
1162
|
+
if (!snapshot?.lease
|
|
1163
|
+
|| !stackIdentity.test(snapshot.lease.stackId)
|
|
1164
|
+
|| !Number.isSafeInteger(snapshot.lease.offset)
|
|
1165
|
+
|| snapshot.lease.offset < 0
|
|
1166
|
+
|| Object.keys(snapshot.lease.ports).length === 0
|
|
1167
|
+
|| Object.entries(snapshot.lease.ports).some(([key, value]) => (!portKey.test(key) || !Number.isSafeInteger(value) || value < 1 || value > 65535))
|
|
1168
|
+
|| new Set(Object.values(snapshot.lease.ports)).size !== Object.keys(snapshot.lease.ports).length) {
|
|
1169
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'an accepted port lease is required before deriving local addresses', '');
|
|
1170
|
+
}
|
|
1171
|
+
const values = Object.entries(snapshot.lease.ports).map(([port, value]) => ({
|
|
1172
|
+
name: port.toLowerCase().replaceAll('_', '-'), kind: 'entrypoint', port,
|
|
1173
|
+
value: `http://127.0.0.1:${value}/`,
|
|
1174
|
+
}));
|
|
1175
|
+
const names = new Set(values.map(({ name }) => name));
|
|
1176
|
+
const diagnostics = [];
|
|
1177
|
+
for (const declaration of snapshot.declarations) {
|
|
1178
|
+
if (!isEntrypointKind(declaration.kind) || !EntrypointDerivative.safeParse(declaration).success) {
|
|
1179
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'entrypoint declaration is malformed', declaration.name);
|
|
1180
|
+
}
|
|
1181
|
+
if (names.has(declaration.name)) {
|
|
1182
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'entrypoint declaration duplicates a rendered name', declaration.name);
|
|
1183
|
+
}
|
|
1184
|
+
names.add(declaration.name);
|
|
1185
|
+
const port = snapshot.lease.ports[declaration.port];
|
|
1186
|
+
if (!Number.isSafeInteger(port) || port === undefined || port < 1 || port > 65535) {
|
|
1187
|
+
return refuse(ENVIRONMENT_CODES.PORT_FAMILY_OUT_OF_RANGE, 'entrypoint references a port absent from the accepted lease', declaration.port);
|
|
1188
|
+
}
|
|
1189
|
+
const value = `${declaration.scheme ?? 'http'}://${declaration.hostname ?? '127.0.0.1'}:${port}${declaration.path}`;
|
|
1190
|
+
values.push({ name: declaration.name, kind: declaration.kind, port: declaration.port, value });
|
|
1191
|
+
const declared = snapshot.declaredValues?.[declaration.name];
|
|
1192
|
+
if (declared !== undefined && declared !== value) {
|
|
1193
|
+
diagnostics.push(isLoopback(declared)
|
|
1194
|
+
? diagnostic('warning', ENVIRONMENT_CODES.PORT_LOCAL_VALUE_CORRECTED, 'stale loopback value was replaced from the accepted lease', declaration.name, `use ${value}`)
|
|
1195
|
+
: diagnostic('info', ENVIRONMENT_CODES.PORT_EXTERNAL_REGISTRATION_GAP, 'external registration cannot be updated by cockpit', declaration.name, `update the external registration to ${value}`));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const result = {
|
|
1199
|
+
kind: 'complete',
|
|
1200
|
+
values: values.map((entrypoint) => ({ ...entrypoint })),
|
|
1201
|
+
diagnostics: diagnostics.map((item) => ({ ...item })),
|
|
1202
|
+
};
|
|
1203
|
+
issuedEntrypointResults.set(result, { stackId: snapshot.lease.stackId, ports: Object.freeze({ ...snapshot.lease.ports }) });
|
|
1204
|
+
return result;
|
|
1205
|
+
}
|