owlrunkit 0.15.0 → 0.16.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 +100 -0
- package/README.md +274 -66
- package/integrations/codex/skills/owlcoda-runkit/SKILL.md +223 -117
- package/integrations/codex/skills/owlcoda-runkit/assets/templates/config.json +3 -3
- package/integrations/codex/skills/owlcoda-runkit/assets/templates/ready-for-commit-request.json +2 -2
- package/package.json +2 -2
- package/packages/attest/distribution-manifest-v1.json +10 -0
- package/packages/attest/src/quick.mjs +10 -0
- package/registry-release-policy-v1.json +8 -2
- package/sbom.spdx.json +5 -5
- package/scripts/runkit-contract/assurance-router.mjs +119 -0
- package/scripts/runkit-contract/core-contract.mjs +250 -115
- package/scripts/runkit-contract/core-successor.mjs +2043 -0
- package/scripts/runkit-contract/deployment-workflow.mjs +2040 -0
- package/scripts/runkit-contract/finalize.mjs +242 -21
- package/scripts/runkit-contract/fleet-discovery.mjs +456 -0
- package/scripts/runkit-contract/formal-workflow.mjs +850 -0
- package/scripts/runkit-contract/human-status.mjs +553 -0
- package/scripts/runkit-contract/inspect-presentation.mjs +25 -8
- package/scripts/runkit-contract/install-codex-skill.mjs +73 -30
- package/scripts/runkit-contract/lease-lifecycle.mjs +8 -2
- package/scripts/runkit-contract/lifecycle-orchestration.mjs +135 -12
- package/scripts/runkit-contract/onboarding-doctor.mjs +43 -90
- package/scripts/runkit-contract/owner-authority-trust.mjs +230 -0
- package/scripts/runkit-contract/owner-deployment-decision.mjs +385 -0
- package/scripts/runkit-contract/profile-onboarding.mjs +841 -13
- package/scripts/runkit-contract/project-cli-resolver.mjs +701 -0
- package/scripts/runkit-contract/project-control-parser.mjs +830 -0
- package/scripts/runkit-contract/project-control-state.mjs +28 -0
- package/scripts/runkit-contract/provenance-common.mjs +19 -2
- package/scripts/runkit-contract/ready-for-commit.mjs +330 -18
- package/scripts/runkit-contract/remote-deployment.mjs +1596 -0
- package/scripts/runkit-contract/remote-process-adapter.mjs +177 -0
- package/scripts/runkit-contract/runkit-bootstrap.mjs +131 -0
- package/scripts/runkit-contract/runkit-cli.mjs +1097 -21
- package/scripts/runkit-contract/schemas/assurance-route-v1.schema.json +26 -0
- package/scripts/runkit-contract/schemas/core-successor-batch-receipt-v1.schema.json +126 -0
- package/scripts/runkit-contract/schemas/core-successor-plan-v1.schema.json +155 -0
- package/scripts/runkit-contract/schemas/core-successor-project-success-receipt-v1.schema.json +72 -0
- package/scripts/runkit-contract/schemas/deployment-execute-request-v1.schema.json +63 -0
- package/scripts/runkit-contract/schemas/deployment-lineage-v1.schema.json +292 -0
- package/scripts/runkit-contract/schemas/deployment-prepare-receipt-v1.schema.json +140 -0
- package/scripts/runkit-contract/schemas/fleet-discovery-v1.schema.json +31 -0
- package/scripts/runkit-contract/schemas/fleet-manifest-v1.schema.json +20 -0
- package/scripts/runkit-contract/schemas/fleet-root-registry-v1.schema.json +34 -0
- package/scripts/runkit-contract/schemas/human-status-v1.schema.json +102 -0
- package/scripts/runkit-contract/schemas/owner-authority-trust-v1.schema.json +66 -0
- package/scripts/runkit-contract/schemas/owner-deployment-authority-v1.schema.json +46 -0
- package/scripts/runkit-contract/schemas/owner-deployment-authority-v2.schema.json +94 -0
- package/scripts/runkit-contract/schemas/owner-deployment-decision-v1.schema.json +102 -0
- package/scripts/runkit-contract/schemas/owner-migration-authority-v1.schema.json +33 -0
- package/scripts/runkit-contract/schemas/owner-migration-authority-v2.schema.json +67 -0
- package/scripts/runkit-contract/schemas/profile-apply-receipt-v1.schema.json +32 -0
- package/scripts/runkit-contract/schemas/profile-detection-v2.schema.json +145 -0
- package/scripts/runkit-contract/schemas/ready-for-commit-v2.schema.json +37 -0
- package/scripts/runkit-contract/schemas/remote-deployment-manifest-v1.schema.json +379 -0
- package/scripts/runkit-contract/schemas/remote-deployment-result-v1.schema.json +153 -0
- package/scripts/runkit-contract/schemas/remote-deployment-stage-journal-v1.schema.json +183 -0
- package/scripts/runkit-contract/schemas/remote-target-v1.schema.json +32 -0
- package/scripts/runkit-contract/schemas/source-candidate-v2.schema.json +152 -0
- package/scripts/runkit-contract/schemas/verification-receipt-v2.schema.json +13 -0
- package/scripts/runkit-contract/snapshot.mjs +1 -1
- package/scripts/runkit-contract/source-candidate.mjs +1382 -0
- package/scripts/runkit-contract/ssh-remote-adapter.mjs +1184 -0
- package/scripts/runkit-contract/sync-core016-release-identity.mjs +161 -0
- package/scripts/runkit-contract/templates/assurance-request.json +13 -0
- package/scripts/runkit-contract/templates/core-successor-owner-authority.json +15 -0
- package/scripts/runkit-contract/templates/deployment-execute-request.json +39 -0
- package/scripts/runkit-contract/templates/deployment-owner-authority.json +23 -0
- package/scripts/runkit-contract/templates/deployment-owner-decision.json +26 -0
- package/scripts/runkit-contract/templates/fleet-manifest.json +8 -0
- package/scripts/runkit-contract/templates/fleet-root-registry.json +8 -0
- package/scripts/runkit-contract/templates/owner-authority-trust.json +16 -0
- package/scripts/runkit-contract/templates/ready-for-commit-request.json +2 -2
- package/scripts/runkit-contract/templates/remote-deployment-manifest.json +108 -0
- package/scripts/runkit-contract/verification-receipt-gate.mjs +28 -0
|
@@ -0,0 +1,1596 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assertAllowedKeys,
|
|
15
|
+
isRecord,
|
|
16
|
+
safeIdentifier,
|
|
17
|
+
safeRelativePath,
|
|
18
|
+
sha256,
|
|
19
|
+
} from "./provenance-common.mjs";
|
|
20
|
+
import {
|
|
21
|
+
validateOwnerDeploymentDecisionBindingV1,
|
|
22
|
+
} from "./owner-deployment-decision.mjs";
|
|
23
|
+
|
|
24
|
+
export const REMOTE_DEPLOYMENT_STAGES = Object.freeze([
|
|
25
|
+
"identity_preflight",
|
|
26
|
+
"upload",
|
|
27
|
+
"verify_remote_hashes",
|
|
28
|
+
"install",
|
|
29
|
+
"service",
|
|
30
|
+
"proxy",
|
|
31
|
+
"smoke",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const SSH_REMOTE_HELPER_PROTOCOL_V1 =
|
|
35
|
+
"OwlCodaRunKitSshRemoteHelperV1";
|
|
36
|
+
export const SSH_REMOTE_HELPER_CAPABILITIES_V1 = Object.freeze([
|
|
37
|
+
"execute",
|
|
38
|
+
"reconcile",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const MANIFEST_KEYS = [
|
|
42
|
+
"schemaVersion",
|
|
43
|
+
"deploymentId",
|
|
44
|
+
"deploymentLineageSha256",
|
|
45
|
+
"mode",
|
|
46
|
+
"ownerDecision",
|
|
47
|
+
"serviceActivation",
|
|
48
|
+
"baselineCut",
|
|
49
|
+
"target",
|
|
50
|
+
"adapter",
|
|
51
|
+
"credentialRef",
|
|
52
|
+
"artifact",
|
|
53
|
+
"upload",
|
|
54
|
+
"priorDeployment",
|
|
55
|
+
"expectedRemoteFiles",
|
|
56
|
+
"deletionAllowlist",
|
|
57
|
+
];
|
|
58
|
+
const TARGET_KEYS = [
|
|
59
|
+
"schemaVersion",
|
|
60
|
+
"targetId",
|
|
61
|
+
"environment",
|
|
62
|
+
"host",
|
|
63
|
+
"port",
|
|
64
|
+
"user",
|
|
65
|
+
"hostKeySha256",
|
|
66
|
+
"machineIdentitySha256",
|
|
67
|
+
];
|
|
68
|
+
const PROCESS_ADAPTER_KEYS = [
|
|
69
|
+
"kind",
|
|
70
|
+
"adapterId",
|
|
71
|
+
"version",
|
|
72
|
+
"executable",
|
|
73
|
+
"sha256",
|
|
74
|
+
];
|
|
75
|
+
const BUILTIN_SSH_ADAPTER_KEYS = [
|
|
76
|
+
"kind",
|
|
77
|
+
"adapterId",
|
|
78
|
+
"version",
|
|
79
|
+
"executable",
|
|
80
|
+
"sha256",
|
|
81
|
+
"knownHostsPath",
|
|
82
|
+
"sshExecutable",
|
|
83
|
+
"sshExecutableSha256",
|
|
84
|
+
"remoteHelper",
|
|
85
|
+
"authentication",
|
|
86
|
+
"stageContracts",
|
|
87
|
+
];
|
|
88
|
+
const REMOTE_HELPER_KEYS = [
|
|
89
|
+
"path",
|
|
90
|
+
"protocol",
|
|
91
|
+
"version",
|
|
92
|
+
"capabilities",
|
|
93
|
+
];
|
|
94
|
+
const AUTHENTICATION_KEYS = ["mode", "identityFile"];
|
|
95
|
+
const IDENTITY_FILE_KEYS = ["path", "sha256"];
|
|
96
|
+
const STAGE_CONTRACT_KEYS = ["install", "systemd", "nginx", "smoke"];
|
|
97
|
+
const INSTALL_CONTRACT_KEYS = [
|
|
98
|
+
"archiveFormat",
|
|
99
|
+
"releaseRoot",
|
|
100
|
+
"currentSymlink",
|
|
101
|
+
];
|
|
102
|
+
const SYSTEMD_CONTRACT_KEYS = [
|
|
103
|
+
"unitName",
|
|
104
|
+
"unitFile",
|
|
105
|
+
"daemonReload",
|
|
106
|
+
"enable",
|
|
107
|
+
"restart",
|
|
108
|
+
];
|
|
109
|
+
const NGINX_CONTRACT_KEYS = [
|
|
110
|
+
"siteName",
|
|
111
|
+
"configFile",
|
|
112
|
+
"enabledLinkPath",
|
|
113
|
+
"configTest",
|
|
114
|
+
"reload",
|
|
115
|
+
];
|
|
116
|
+
const MANAGED_FILE_KEYS = ["sourcePath", "destinationPath", "sha256"];
|
|
117
|
+
const ARTIFACT_KEYS = ["path", "sha256", "size", "mediaType"];
|
|
118
|
+
const UPLOAD_KEYS = ["remotePath", "createOnly"];
|
|
119
|
+
const PRIOR_KEYS = ["receiptSha256", "artifactSha256"];
|
|
120
|
+
const EXPECTED_FILE_KEYS = ["path", "sha256"];
|
|
121
|
+
const DELETION_KEYS = ["path", "priorSha256"];
|
|
122
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
123
|
+
const SECRET_KEY = /(?:password|passwd|secret|token|private[_-]?key|credential)/i;
|
|
124
|
+
const CREDENTIAL_REF = /^(?:agent|keychain|vault):[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
125
|
+
const SAFE_HOSTNAME =
|
|
126
|
+
/^(?=.{1,253}\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$/u;
|
|
127
|
+
const SAFE_REMOTE_PATH =
|
|
128
|
+
/^\/(?:[A-Za-z0-9._@%+=,-]+\/)*[A-Za-z0-9._@%+=,-]+$/u;
|
|
129
|
+
const SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*\.service$/u;
|
|
130
|
+
const IDEMPOTENT_REMOTE_STAGES = new Set([
|
|
131
|
+
"identity_preflight",
|
|
132
|
+
"upload",
|
|
133
|
+
"verify_remote_hashes",
|
|
134
|
+
"smoke",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
function canonicalJson(value) {
|
|
138
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
139
|
+
if (value && typeof value === "object") {
|
|
140
|
+
return `{${Object.keys(value).sort()
|
|
141
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
142
|
+
.join(",")}}`;
|
|
143
|
+
}
|
|
144
|
+
const encoded = JSON.stringify(value);
|
|
145
|
+
if (encoded === undefined) throw new Error("Remote deployment value is not canonical JSON.");
|
|
146
|
+
return encoded;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hashObject(value) {
|
|
150
|
+
return sha256(Buffer.from(canonicalJson(value), "utf8"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertHash(value, label) {
|
|
154
|
+
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
155
|
+
throw new Error(`${label} must be a lowercase SHA-256 digest.`);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertExactRemotePath(value, label) {
|
|
161
|
+
if (typeof value !== "string"
|
|
162
|
+
|| !value.startsWith("/")
|
|
163
|
+
|| value === "/"
|
|
164
|
+
|| value.includes("\0")
|
|
165
|
+
|| !SAFE_REMOTE_PATH.test(value)
|
|
166
|
+
|| path.posix.normalize(value) !== value) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${label} must be an exact remote path without shell metacharacters, globs, or traversal.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasSecretShapedField(value, { allowCredentialRef = false } = {}) {
|
|
175
|
+
if (Array.isArray(value)) {
|
|
176
|
+
return value.some(item => hasSecretShapedField(item, { allowCredentialRef }));
|
|
177
|
+
}
|
|
178
|
+
if (!isRecord(value)) return false;
|
|
179
|
+
return Object.entries(value).some(([key, nested]) => {
|
|
180
|
+
if (allowCredentialRef && key === "credentialRef") return false;
|
|
181
|
+
return SECRET_KEY.test(key)
|
|
182
|
+
|| hasSecretShapedField(nested, { allowCredentialRef });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validateRemoteTarget(target) {
|
|
187
|
+
assertAllowedKeys(target, "Remote target", TARGET_KEYS);
|
|
188
|
+
if (target.schemaVersion !== "OwlCodaRunKitRemoteTargetV1") {
|
|
189
|
+
throw new Error("Unsupported remote target schemaVersion.");
|
|
190
|
+
}
|
|
191
|
+
safeIdentifier(target.targetId, "Remote targetId");
|
|
192
|
+
safeIdentifier(target.environment, "Remote environment");
|
|
193
|
+
if (typeof target.host !== "string"
|
|
194
|
+
|| target.host.length === 0
|
|
195
|
+
|| (isIP(target.host) === 0 && !SAFE_HOSTNAME.test(target.host))) {
|
|
196
|
+
throw new Error("Remote target host must be an exact host name or address.");
|
|
197
|
+
}
|
|
198
|
+
if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
|
|
199
|
+
throw new Error("Remote target port must be an integer from 1 to 65535.");
|
|
200
|
+
}
|
|
201
|
+
if (typeof target.user !== "string"
|
|
202
|
+
|| !/^[A-Za-z_][A-Za-z0-9._-]*$/.test(target.user)) {
|
|
203
|
+
throw new Error("Remote target user is invalid.");
|
|
204
|
+
}
|
|
205
|
+
assertHash(target.hostKeySha256, "Remote host key");
|
|
206
|
+
assertHash(target.machineIdentitySha256, "Remote machine identity");
|
|
207
|
+
return structuredClone(target);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function validateSshRemoteHelperBindingV1(binding) {
|
|
211
|
+
if (binding === undefined || binding === null) {
|
|
212
|
+
throw new Error("SSH remote helper binding is required.");
|
|
213
|
+
}
|
|
214
|
+
assertAllowedKeys(
|
|
215
|
+
binding,
|
|
216
|
+
"SSH remote helper binding",
|
|
217
|
+
REMOTE_HELPER_KEYS,
|
|
218
|
+
);
|
|
219
|
+
const helperPath = assertExactRemotePath(
|
|
220
|
+
binding.path,
|
|
221
|
+
"SSH remote helper path",
|
|
222
|
+
);
|
|
223
|
+
if (path.posix.basename(helperPath) !== "owlrunkit-remote-helper") {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"SSH remote helper executable name must be owlrunkit-remote-helper.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (binding.protocol !== SSH_REMOTE_HELPER_PROTOCOL_V1) {
|
|
229
|
+
throw new Error("SSH remote helper protocol is unsupported.");
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
typeof binding.version !== "string"
|
|
233
|
+
|| !/^\d+\.\d+\.\d+$/u.test(binding.version)
|
|
234
|
+
) {
|
|
235
|
+
throw new Error("SSH remote helper version must be an exact semantic version.");
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
!Array.isArray(binding.capabilities)
|
|
239
|
+
|| binding.capabilities.length
|
|
240
|
+
!== SSH_REMOTE_HELPER_CAPABILITIES_V1.length
|
|
241
|
+
|| binding.capabilities.some(
|
|
242
|
+
(capability, index) => (
|
|
243
|
+
capability !== SSH_REMOTE_HELPER_CAPABILITIES_V1[index]
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"SSH remote helper capabilities must be exactly execute and reconcile.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
path: helperPath,
|
|
253
|
+
protocol: binding.protocol,
|
|
254
|
+
version: binding.version,
|
|
255
|
+
capabilities: [...binding.capabilities],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateAdapterIdentityFields(adapter) {
|
|
260
|
+
safeIdentifier(adapter.adapterId, "Remote adapterId");
|
|
261
|
+
if (typeof adapter.version !== "string" || adapter.version.length === 0) {
|
|
262
|
+
throw new Error("Remote adapter version is required.");
|
|
263
|
+
}
|
|
264
|
+
if (typeof adapter.executable !== "string" || !path.isAbsolute(adapter.executable)) {
|
|
265
|
+
throw new Error("Remote adapter executable must be absolute.");
|
|
266
|
+
}
|
|
267
|
+
assertHash(adapter.sha256, "Remote adapter");
|
|
268
|
+
return {
|
|
269
|
+
adapterId: adapter.adapterId,
|
|
270
|
+
version: adapter.version,
|
|
271
|
+
executable: adapter.executable,
|
|
272
|
+
sha256: adapter.sha256,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function assertAbsoluteLocalPath(value, label) {
|
|
277
|
+
if (
|
|
278
|
+
typeof value !== "string"
|
|
279
|
+
|| !path.isAbsolute(value)
|
|
280
|
+
|| value.includes("\0")
|
|
281
|
+
|| /[\r\n]/u.test(value)
|
|
282
|
+
|| path.normalize(value) !== value
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(`${label} must be an exact absolute local path.`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validateManagedFile(value, label) {
|
|
290
|
+
assertAllowedKeys(value, label, MANAGED_FILE_KEYS);
|
|
291
|
+
return {
|
|
292
|
+
sourcePath: safeRelativePath(value.sourcePath, `${label} sourcePath`),
|
|
293
|
+
destinationPath: assertExactRemotePath(
|
|
294
|
+
value.destinationPath,
|
|
295
|
+
`${label} destinationPath`,
|
|
296
|
+
),
|
|
297
|
+
sha256: assertHash(value.sha256, `${label} SHA-256`),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateSshStageContracts(contracts) {
|
|
302
|
+
assertAllowedKeys(contracts, "Built-in SSH stage contracts", STAGE_CONTRACT_KEYS);
|
|
303
|
+
assertAllowedKeys(
|
|
304
|
+
contracts.install,
|
|
305
|
+
"Built-in SSH install contract",
|
|
306
|
+
INSTALL_CONTRACT_KEYS,
|
|
307
|
+
);
|
|
308
|
+
if (contracts.install.archiveFormat !== "tar_gzip") {
|
|
309
|
+
throw new Error("Built-in SSH archiveFormat must be tar_gzip.");
|
|
310
|
+
}
|
|
311
|
+
const install = {
|
|
312
|
+
archiveFormat: "tar_gzip",
|
|
313
|
+
releaseRoot: assertExactRemotePath(
|
|
314
|
+
contracts.install.releaseRoot,
|
|
315
|
+
"Built-in SSH releaseRoot",
|
|
316
|
+
),
|
|
317
|
+
currentSymlink: assertExactRemotePath(
|
|
318
|
+
contracts.install.currentSymlink,
|
|
319
|
+
"Built-in SSH currentSymlink",
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
assertAllowedKeys(
|
|
323
|
+
contracts.systemd,
|
|
324
|
+
"Built-in SSH systemd contract",
|
|
325
|
+
SYSTEMD_CONTRACT_KEYS,
|
|
326
|
+
);
|
|
327
|
+
if (
|
|
328
|
+
typeof contracts.systemd.unitName !== "string"
|
|
329
|
+
|| !SERVICE_NAME.test(contracts.systemd.unitName)
|
|
330
|
+
) {
|
|
331
|
+
throw new Error("Built-in SSH systemd unitName is invalid.");
|
|
332
|
+
}
|
|
333
|
+
for (const key of ["daemonReload", "enable", "restart"]) {
|
|
334
|
+
if (typeof contracts.systemd[key] !== "boolean") {
|
|
335
|
+
throw new Error(`Built-in SSH systemd ${key} must be boolean.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const systemd = {
|
|
339
|
+
unitName: contracts.systemd.unitName,
|
|
340
|
+
unitFile: validateManagedFile(
|
|
341
|
+
contracts.systemd.unitFile,
|
|
342
|
+
"Built-in SSH systemd unit file",
|
|
343
|
+
),
|
|
344
|
+
daemonReload: contracts.systemd.daemonReload,
|
|
345
|
+
enable: contracts.systemd.enable,
|
|
346
|
+
restart: contracts.systemd.restart,
|
|
347
|
+
};
|
|
348
|
+
assertAllowedKeys(
|
|
349
|
+
contracts.nginx,
|
|
350
|
+
"Built-in SSH Nginx contract",
|
|
351
|
+
NGINX_CONTRACT_KEYS,
|
|
352
|
+
);
|
|
353
|
+
safeIdentifier(contracts.nginx.siteName, "Built-in SSH Nginx siteName");
|
|
354
|
+
for (const key of ["configTest", "reload"]) {
|
|
355
|
+
if (typeof contracts.nginx[key] !== "boolean") {
|
|
356
|
+
throw new Error(`Built-in SSH Nginx ${key} must be boolean.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const nginx = {
|
|
360
|
+
siteName: contracts.nginx.siteName,
|
|
361
|
+
configFile: validateManagedFile(
|
|
362
|
+
contracts.nginx.configFile,
|
|
363
|
+
"Built-in SSH Nginx config file",
|
|
364
|
+
),
|
|
365
|
+
enabledLinkPath: assertExactRemotePath(
|
|
366
|
+
contracts.nginx.enabledLinkPath,
|
|
367
|
+
"Built-in SSH Nginx enabledLinkPath",
|
|
368
|
+
),
|
|
369
|
+
configTest: contracts.nginx.configTest,
|
|
370
|
+
reload: contracts.nginx.reload,
|
|
371
|
+
};
|
|
372
|
+
assertAllowedKeys(contracts.smoke, "Built-in SSH smoke contract", ["checks"]);
|
|
373
|
+
if (!Array.isArray(contracts.smoke.checks)
|
|
374
|
+
|| contracts.smoke.checks.length === 0) {
|
|
375
|
+
throw new Error("Built-in SSH smoke contract requires checks.");
|
|
376
|
+
}
|
|
377
|
+
const checks = contracts.smoke.checks.map((check) => {
|
|
378
|
+
if (check?.kind === "systemd_active") {
|
|
379
|
+
assertAllowedKeys(check, "Built-in SSH systemd smoke check", [
|
|
380
|
+
"checkId",
|
|
381
|
+
"kind",
|
|
382
|
+
"unitName",
|
|
383
|
+
]);
|
|
384
|
+
safeIdentifier(check.checkId, "Built-in SSH smoke checkId");
|
|
385
|
+
if (typeof check.unitName !== "string" || !SERVICE_NAME.test(check.unitName)) {
|
|
386
|
+
throw new Error("Built-in SSH smoke unitName is invalid.");
|
|
387
|
+
}
|
|
388
|
+
return structuredClone(check);
|
|
389
|
+
}
|
|
390
|
+
if (check?.kind === "http") {
|
|
391
|
+
assertAllowedKeys(check, "Built-in SSH HTTP smoke check", [
|
|
392
|
+
"checkId",
|
|
393
|
+
"kind",
|
|
394
|
+
"url",
|
|
395
|
+
"expectedStatus",
|
|
396
|
+
]);
|
|
397
|
+
safeIdentifier(check.checkId, "Built-in SSH smoke checkId");
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = new URL(check.url);
|
|
401
|
+
} catch {
|
|
402
|
+
throw new Error("Built-in SSH smoke URL is invalid.");
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
parsed.protocol !== "http:"
|
|
406
|
+
|| !new Set(["127.0.0.1", "[::1]", "localhost"]).has(parsed.hostname)
|
|
407
|
+
|| parsed.username !== ""
|
|
408
|
+
|| parsed.password !== ""
|
|
409
|
+
|| parsed.search !== ""
|
|
410
|
+
|| parsed.hash !== ""
|
|
411
|
+
|| !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u.test(parsed.pathname)
|
|
412
|
+
|| !Number.isInteger(check.expectedStatus)
|
|
413
|
+
|| check.expectedStatus < 100
|
|
414
|
+
|| check.expectedStatus > 599
|
|
415
|
+
) {
|
|
416
|
+
throw new Error("Built-in SSH HTTP smoke check is invalid.");
|
|
417
|
+
}
|
|
418
|
+
return structuredClone(check);
|
|
419
|
+
}
|
|
420
|
+
throw new Error("Built-in SSH smoke check kind is unsupported.");
|
|
421
|
+
});
|
|
422
|
+
if (new Set(checks.map(check => check.checkId)).size !== checks.length) {
|
|
423
|
+
throw new Error("Built-in SSH smoke checkId values must be unique.");
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
install,
|
|
427
|
+
systemd,
|
|
428
|
+
nginx,
|
|
429
|
+
smoke: { checks },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function validateBuiltinSshAdapter(adapter) {
|
|
434
|
+
assertAllowedKeys(
|
|
435
|
+
adapter,
|
|
436
|
+
"Built-in SSH adapter declaration",
|
|
437
|
+
BUILTIN_SSH_ADAPTER_KEYS,
|
|
438
|
+
);
|
|
439
|
+
const identity = validateAdapterIdentityFields(adapter);
|
|
440
|
+
if (
|
|
441
|
+
adapter.kind !== "builtin_ssh"
|
|
442
|
+
|| identity.adapterId !== "builtin-ssh-v1"
|
|
443
|
+
|| identity.version !== "1.0.0"
|
|
444
|
+
) {
|
|
445
|
+
throw new Error("Built-in SSH adapter identity is invalid.");
|
|
446
|
+
}
|
|
447
|
+
assertAllowedKeys(
|
|
448
|
+
adapter.authentication,
|
|
449
|
+
"Built-in SSH authentication binding",
|
|
450
|
+
AUTHENTICATION_KEYS,
|
|
451
|
+
);
|
|
452
|
+
let identityFile = null;
|
|
453
|
+
if (adapter.authentication.mode === "agent") {
|
|
454
|
+
if (adapter.authentication.identityFile !== null) {
|
|
455
|
+
throw new Error("Agent SSH authentication cannot declare an identity file.");
|
|
456
|
+
}
|
|
457
|
+
} else if (adapter.authentication.mode === "identity_file") {
|
|
458
|
+
assertAllowedKeys(
|
|
459
|
+
adapter.authentication.identityFile,
|
|
460
|
+
"Built-in SSH identity file binding",
|
|
461
|
+
IDENTITY_FILE_KEYS,
|
|
462
|
+
);
|
|
463
|
+
identityFile = {
|
|
464
|
+
path: assertAbsoluteLocalPath(
|
|
465
|
+
adapter.authentication.identityFile.path,
|
|
466
|
+
"Built-in SSH identity file",
|
|
467
|
+
),
|
|
468
|
+
sha256: assertHash(
|
|
469
|
+
adapter.authentication.identityFile.sha256,
|
|
470
|
+
"Built-in SSH identity file",
|
|
471
|
+
),
|
|
472
|
+
};
|
|
473
|
+
} else {
|
|
474
|
+
throw new Error("Built-in SSH authentication mode is invalid.");
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
...identity,
|
|
478
|
+
kind: "builtin_ssh",
|
|
479
|
+
knownHostsPath: assertAbsoluteLocalPath(
|
|
480
|
+
adapter.knownHostsPath,
|
|
481
|
+
"Built-in SSH known_hosts",
|
|
482
|
+
),
|
|
483
|
+
sshExecutable: assertAbsoluteLocalPath(
|
|
484
|
+
adapter.sshExecutable,
|
|
485
|
+
"Built-in SSH executable",
|
|
486
|
+
),
|
|
487
|
+
sshExecutableSha256: assertHash(
|
|
488
|
+
adapter.sshExecutableSha256,
|
|
489
|
+
"Built-in SSH executable",
|
|
490
|
+
),
|
|
491
|
+
remoteHelper: validateSshRemoteHelperBindingV1(adapter.remoteHelper),
|
|
492
|
+
authentication: {
|
|
493
|
+
mode: adapter.authentication.mode,
|
|
494
|
+
identityFile,
|
|
495
|
+
},
|
|
496
|
+
stageContracts: validateSshStageContracts(adapter.stageContracts),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function validateAdapterDeclaration(adapter) {
|
|
501
|
+
if (adapter?.kind === "builtin_ssh") {
|
|
502
|
+
return validateBuiltinSshAdapter(adapter);
|
|
503
|
+
}
|
|
504
|
+
const allowed = adapter?.kind === "process"
|
|
505
|
+
? PROCESS_ADAPTER_KEYS
|
|
506
|
+
: PROCESS_ADAPTER_KEYS.filter(key => key !== "kind");
|
|
507
|
+
assertAllowedKeys(adapter, "Remote process adapter identity", allowed);
|
|
508
|
+
if (adapter.kind !== undefined && adapter.kind !== "process") {
|
|
509
|
+
throw new Error("Remote adapter kind is unsupported.");
|
|
510
|
+
}
|
|
511
|
+
const identity = validateAdapterIdentityFields(adapter);
|
|
512
|
+
return adapter.kind === "process"
|
|
513
|
+
? { kind: "process", ...identity }
|
|
514
|
+
: identity;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function adapterRuntimeIdentity(adapter) {
|
|
518
|
+
return validateAdapterIdentityFields(adapter);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function validateArtifact(artifact) {
|
|
522
|
+
assertAllowedKeys(artifact, "Remote artifact", ARTIFACT_KEYS);
|
|
523
|
+
safeRelativePath(artifact.path, "Remote artifact path");
|
|
524
|
+
assertHash(artifact.sha256, "Remote artifact");
|
|
525
|
+
if (!Number.isInteger(artifact.size) || artifact.size < 0) {
|
|
526
|
+
throw new Error("Remote artifact size must be a non-negative integer.");
|
|
527
|
+
}
|
|
528
|
+
if (typeof artifact.mediaType !== "string"
|
|
529
|
+
|| !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(artifact.mediaType)) {
|
|
530
|
+
throw new Error("Remote artifact mediaType is invalid.");
|
|
531
|
+
}
|
|
532
|
+
return structuredClone(artifact);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function validateExpectedFiles(entries) {
|
|
536
|
+
if (!Array.isArray(entries)) throw new Error("expectedRemoteFiles must be an array.");
|
|
537
|
+
const paths = new Set();
|
|
538
|
+
return entries.map((entry) => {
|
|
539
|
+
assertAllowedKeys(entry, "Expected remote file", EXPECTED_FILE_KEYS);
|
|
540
|
+
const remotePath = assertExactRemotePath(entry.path, "Expected remote file path");
|
|
541
|
+
if (paths.has(remotePath)) throw new Error(`Duplicate expected remote file: ${remotePath}`);
|
|
542
|
+
paths.add(remotePath);
|
|
543
|
+
return { path: remotePath, sha256: assertHash(entry.sha256, "Expected remote file") };
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function validateDeletionAllowlist(entries, expectedRemoteFiles) {
|
|
548
|
+
if (!Array.isArray(entries)) throw new Error("deletionAllowlist must be an array.");
|
|
549
|
+
const expected = new Map(expectedRemoteFiles.map(entry => [entry.path, entry.sha256]));
|
|
550
|
+
const paths = new Set();
|
|
551
|
+
return entries.map((entry) => {
|
|
552
|
+
assertAllowedKeys(entry, "Deletion allowlist entry", DELETION_KEYS);
|
|
553
|
+
const remotePath = assertExactRemotePath(entry.path, "Deletion allowlist path");
|
|
554
|
+
if (paths.has(remotePath)) throw new Error(`Duplicate deletion allowlist path: ${remotePath}`);
|
|
555
|
+
paths.add(remotePath);
|
|
556
|
+
const priorSha256 = assertHash(entry.priorSha256, "Deletion prior hash");
|
|
557
|
+
if (expected.get(remotePath) !== priorSha256) {
|
|
558
|
+
throw new Error(`Deletion allowlist entry does not match the expected prior hash: ${remotePath}`);
|
|
559
|
+
}
|
|
560
|
+
return { path: remotePath, priorSha256 };
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function normalizeRemoteDeploymentManifest(
|
|
565
|
+
manifest,
|
|
566
|
+
{ allowUnboundLineage = false } = {},
|
|
567
|
+
) {
|
|
568
|
+
if (hasSecretShapedField(manifest, { allowCredentialRef: true })) {
|
|
569
|
+
throw new Error("Remote deployment manifest contains secret material.");
|
|
570
|
+
}
|
|
571
|
+
assertAllowedKeys(manifest, "Remote deployment manifest", MANIFEST_KEYS);
|
|
572
|
+
if (manifest.schemaVersion !== "OwlCodaRunKitRemoteDeploymentManifestV1") {
|
|
573
|
+
throw new Error("Unsupported remote deployment manifest schemaVersion.");
|
|
574
|
+
}
|
|
575
|
+
safeIdentifier(manifest.deploymentId, "Remote deploymentId");
|
|
576
|
+
const deploymentLineageSha256 = allowUnboundLineage
|
|
577
|
+
&& manifest.deploymentLineageSha256 === null
|
|
578
|
+
? null
|
|
579
|
+
: assertHash(manifest.deploymentLineageSha256, "Deployment lineage");
|
|
580
|
+
if (!new Set(["first", "update"]).has(manifest.mode)) {
|
|
581
|
+
throw new Error("Remote deployment mode must be first or update.");
|
|
582
|
+
}
|
|
583
|
+
const target = validateRemoteTarget(manifest.target);
|
|
584
|
+
const adapter = validateAdapterDeclaration(manifest.adapter);
|
|
585
|
+
if (typeof manifest.credentialRef !== "string"
|
|
586
|
+
|| !CREDENTIAL_REF.test(manifest.credentialRef)) {
|
|
587
|
+
throw new Error("Remote deployment credentialRef must be an opaque credential reference.");
|
|
588
|
+
}
|
|
589
|
+
const artifact = validateArtifact(manifest.artifact);
|
|
590
|
+
assertAllowedKeys(manifest.upload, "Remote upload", UPLOAD_KEYS);
|
|
591
|
+
const upload = {
|
|
592
|
+
remotePath: assertExactRemotePath(manifest.upload.remotePath, "Remote upload path"),
|
|
593
|
+
createOnly: manifest.upload.createOnly,
|
|
594
|
+
};
|
|
595
|
+
if (upload.createOnly !== true) {
|
|
596
|
+
throw new Error("Remote deployment upload must be create-only.");
|
|
597
|
+
}
|
|
598
|
+
const expectedRemoteFiles = validateExpectedFiles(manifest.expectedRemoteFiles);
|
|
599
|
+
let priorDeployment = null;
|
|
600
|
+
if (manifest.mode === "first") {
|
|
601
|
+
if (manifest.priorDeployment !== null) {
|
|
602
|
+
throw new Error("First deployment priorDeployment must be null.");
|
|
603
|
+
}
|
|
604
|
+
if (expectedRemoteFiles.length > 0 || manifest.deletionAllowlist.length > 0) {
|
|
605
|
+
throw new Error("First deployment cannot declare prior files or deletions.");
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
if (!isRecord(manifest.priorDeployment)) {
|
|
609
|
+
throw new Error("Update deployment priorDeployment must be an object.");
|
|
610
|
+
}
|
|
611
|
+
assertAllowedKeys(manifest.priorDeployment, "Prior deployment", PRIOR_KEYS);
|
|
612
|
+
priorDeployment = {
|
|
613
|
+
receiptSha256: assertHash(
|
|
614
|
+
manifest.priorDeployment.receiptSha256,
|
|
615
|
+
"Prior deployment receipt",
|
|
616
|
+
),
|
|
617
|
+
artifactSha256: assertHash(
|
|
618
|
+
manifest.priorDeployment.artifactSha256,
|
|
619
|
+
"Prior deployment artifact",
|
|
620
|
+
),
|
|
621
|
+
};
|
|
622
|
+
if (expectedRemoteFiles.length === 0) {
|
|
623
|
+
throw new Error("Update deployment requires expected prior remote file hashes.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const deletionAllowlist = validateDeletionAllowlist(
|
|
627
|
+
manifest.deletionAllowlist,
|
|
628
|
+
expectedRemoteFiles,
|
|
629
|
+
);
|
|
630
|
+
const ownerDecision = manifest.ownerDecision === undefined
|
|
631
|
+
? null
|
|
632
|
+
: validateOwnerDeploymentDecisionBindingV1(manifest.ownerDecision);
|
|
633
|
+
for (const field of ["serviceActivation", "baselineCut"]) {
|
|
634
|
+
if (manifest[field] !== undefined && typeof manifest[field] !== "boolean") {
|
|
635
|
+
throw new Error(`Remote deployment ${field} must be boolean when declared.`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
status: "valid",
|
|
640
|
+
normalized: {
|
|
641
|
+
schemaVersion: manifest.schemaVersion,
|
|
642
|
+
deploymentId: manifest.deploymentId,
|
|
643
|
+
deploymentLineageSha256,
|
|
644
|
+
mode: manifest.mode,
|
|
645
|
+
...(ownerDecision === null ? {} : { ownerDecision }),
|
|
646
|
+
...(manifest.serviceActivation === undefined
|
|
647
|
+
? {}
|
|
648
|
+
: { serviceActivation: manifest.serviceActivation }),
|
|
649
|
+
...(manifest.baselineCut === undefined
|
|
650
|
+
? {}
|
|
651
|
+
: { baselineCut: manifest.baselineCut }),
|
|
652
|
+
target,
|
|
653
|
+
adapter,
|
|
654
|
+
credentialRef: manifest.credentialRef,
|
|
655
|
+
artifact,
|
|
656
|
+
upload,
|
|
657
|
+
priorDeployment,
|
|
658
|
+
expectedRemoteFiles,
|
|
659
|
+
deletionAllowlist,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function validateRemoteDeploymentManifest(manifest) {
|
|
665
|
+
return normalizeRemoteDeploymentManifest(manifest);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function canonicalRemoteDeploymentIntentSha256(manifest) {
|
|
669
|
+
const { normalized } = normalizeRemoteDeploymentManifest(
|
|
670
|
+
manifest,
|
|
671
|
+
{ allowUnboundLineage: true },
|
|
672
|
+
);
|
|
673
|
+
if (normalized.deploymentLineageSha256 !== null) {
|
|
674
|
+
throw new Error(
|
|
675
|
+
"Remote deployment intent must leave deploymentLineageSha256 unbound.",
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
const {
|
|
679
|
+
deploymentLineageSha256: _derivedLineage,
|
|
680
|
+
...intent
|
|
681
|
+
} = normalized;
|
|
682
|
+
return hashObject(intent);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function identitiesEqual(left, right) {
|
|
686
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function writeCreateOnlyJson(filePath, value) {
|
|
690
|
+
const bytes = `${JSON.stringify(value, null, 2)}\n`;
|
|
691
|
+
writeFileSync(filePath, bytes, { flag: "wx" });
|
|
692
|
+
return sha256(Buffer.from(bytes, "utf8"));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function readJournalArtifact(filePath, label) {
|
|
696
|
+
const stat = lstatSync(filePath);
|
|
697
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
698
|
+
throw new Error(`${label} must be a regular create-only artifact.`);
|
|
699
|
+
}
|
|
700
|
+
const bytes = readFileSync(filePath);
|
|
701
|
+
let value;
|
|
702
|
+
try {
|
|
703
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
704
|
+
} catch {
|
|
705
|
+
throw new Error(`${label} must contain valid JSON.`);
|
|
706
|
+
}
|
|
707
|
+
return { value, bytes, sha256: sha256(bytes) };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function journalFileName(stage, operation, attempt, phase) {
|
|
711
|
+
const ordinal = String(REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1)
|
|
712
|
+
.padStart(2, "0");
|
|
713
|
+
return `${ordinal}-${stage}-${operation}-attempt-${String(attempt)
|
|
714
|
+
.padStart(3, "0")}-${phase}.json`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function assertJournalBinding(binding) {
|
|
718
|
+
assertAllowedKeys(binding, "Remote deployment journal binding", [
|
|
719
|
+
"deploymentId",
|
|
720
|
+
"deploymentLineageSha256",
|
|
721
|
+
"remoteManifestSha256",
|
|
722
|
+
"executeRequestSha256",
|
|
723
|
+
"adapterIdentity",
|
|
724
|
+
]);
|
|
725
|
+
safeIdentifier(binding.deploymentId, "Remote deployment journal deploymentId");
|
|
726
|
+
assertHash(
|
|
727
|
+
binding.deploymentLineageSha256,
|
|
728
|
+
"Remote deployment journal lineage",
|
|
729
|
+
);
|
|
730
|
+
assertHash(
|
|
731
|
+
binding.remoteManifestSha256,
|
|
732
|
+
"Remote deployment journal manifest",
|
|
733
|
+
);
|
|
734
|
+
assertHash(
|
|
735
|
+
binding.executeRequestSha256,
|
|
736
|
+
"Remote deployment journal execute request",
|
|
737
|
+
);
|
|
738
|
+
const adapterIdentity = validateAdapterIdentityFields(binding.adapterIdentity);
|
|
739
|
+
const body = {
|
|
740
|
+
schemaVersion: "OwlCodaRunKitRemoteDeploymentJournalBindingV1",
|
|
741
|
+
deploymentId: binding.deploymentId,
|
|
742
|
+
deploymentLineageSha256: binding.deploymentLineageSha256,
|
|
743
|
+
remoteManifestSha256: binding.remoteManifestSha256,
|
|
744
|
+
executeRequestSha256: binding.executeRequestSha256,
|
|
745
|
+
adapterIdentity,
|
|
746
|
+
adapterIdentitySha256: hashObject(adapterIdentity),
|
|
747
|
+
authorizationGranted: false,
|
|
748
|
+
};
|
|
749
|
+
return { ...body, bindingSha256: hashObject(body) };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function invocationFiles(root, stage, operation) {
|
|
753
|
+
const prefix = `${String(REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1)
|
|
754
|
+
.padStart(2, "0")}-${stage}-${operation}-attempt-`;
|
|
755
|
+
const names = readdirSync(root);
|
|
756
|
+
const beforeNames = names
|
|
757
|
+
.filter(name => name.startsWith(prefix) && name.endsWith("-before.json"));
|
|
758
|
+
const beforeNameSet = new Set(beforeNames);
|
|
759
|
+
for (const afterName of names.filter(
|
|
760
|
+
name => name.startsWith(prefix) && name.endsWith("-after.json"),
|
|
761
|
+
)) {
|
|
762
|
+
const beforeName = afterName.replace(/-after\.json$/u, "-before.json");
|
|
763
|
+
if (!beforeNameSet.has(beforeName)) {
|
|
764
|
+
throw new Error("Remote deployment stage journal has an orphan after artifact.");
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const files = beforeNames
|
|
768
|
+
.sort()
|
|
769
|
+
.map((name) => {
|
|
770
|
+
const match = name.match(/-attempt-(\d{3})-before\.json$/u);
|
|
771
|
+
if (!match) throw new Error("Remote deployment journal filename is invalid.");
|
|
772
|
+
const attempt = Number.parseInt(match[1], 10);
|
|
773
|
+
const beforePath = path.join(root, name);
|
|
774
|
+
const afterPath = path.join(
|
|
775
|
+
root,
|
|
776
|
+
journalFileName(stage, operation, attempt, "after"),
|
|
777
|
+
);
|
|
778
|
+
return { attempt, beforePath, afterPath };
|
|
779
|
+
});
|
|
780
|
+
if (files.some((entry, index) => entry.attempt !== index + 1)) {
|
|
781
|
+
throw new Error("Remote deployment stage journal attempts are not contiguous.");
|
|
782
|
+
}
|
|
783
|
+
return files;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function createRemoteDeploymentStageJournalV1({
|
|
787
|
+
journalRoot,
|
|
788
|
+
binding,
|
|
789
|
+
}) {
|
|
790
|
+
if (typeof journalRoot !== "string" || !path.isAbsolute(journalRoot)) {
|
|
791
|
+
throw new Error("Remote deployment journal root must be absolute.");
|
|
792
|
+
}
|
|
793
|
+
mkdirSync(journalRoot, { recursive: true });
|
|
794
|
+
const rootStat = lstatSync(journalRoot);
|
|
795
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
796
|
+
throw new Error("Remote deployment journal root must be a real directory.");
|
|
797
|
+
}
|
|
798
|
+
const root = realpathSync(journalRoot);
|
|
799
|
+
const normalizedBinding = assertJournalBinding(binding);
|
|
800
|
+
const bindingPath = path.join(root, "journal-binding.json");
|
|
801
|
+
if (existsSync(bindingPath)) {
|
|
802
|
+
const existing = readJournalArtifact(
|
|
803
|
+
bindingPath,
|
|
804
|
+
"Remote deployment journal binding",
|
|
805
|
+
).value;
|
|
806
|
+
if (!identitiesEqual(existing, normalizedBinding)) {
|
|
807
|
+
throw new Error("Remote deployment journal binding drifted.");
|
|
808
|
+
}
|
|
809
|
+
} else {
|
|
810
|
+
writeCreateOnlyJson(bindingPath, normalizedBinding);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function validateBefore(value, stage, operation) {
|
|
814
|
+
assertAllowedKeys(value, "Remote deployment stage before journal", [
|
|
815
|
+
"schemaVersion",
|
|
816
|
+
"bindingSha256",
|
|
817
|
+
"deploymentId",
|
|
818
|
+
"deploymentLineageSha256",
|
|
819
|
+
"remoteManifestSha256",
|
|
820
|
+
"executeRequestSha256",
|
|
821
|
+
"adapterIdentitySha256",
|
|
822
|
+
"stage",
|
|
823
|
+
"stageOrdinal",
|
|
824
|
+
"operation",
|
|
825
|
+
"attempt",
|
|
826
|
+
"status",
|
|
827
|
+
"authorizationGranted",
|
|
828
|
+
"journalSha256",
|
|
829
|
+
]);
|
|
830
|
+
if (
|
|
831
|
+
value?.schemaVersion !== "OwlCodaRunKitRemoteStageInvocationBeforeV1"
|
|
832
|
+
|| value.bindingSha256 !== normalizedBinding.bindingSha256
|
|
833
|
+
|| value.deploymentId !== normalizedBinding.deploymentId
|
|
834
|
+
|| value.deploymentLineageSha256
|
|
835
|
+
!== normalizedBinding.deploymentLineageSha256
|
|
836
|
+
|| value.remoteManifestSha256
|
|
837
|
+
!== normalizedBinding.remoteManifestSha256
|
|
838
|
+
|| value.executeRequestSha256
|
|
839
|
+
!== normalizedBinding.executeRequestSha256
|
|
840
|
+
|| value.adapterIdentitySha256
|
|
841
|
+
!== normalizedBinding.adapterIdentitySha256
|
|
842
|
+
|| value.stage !== stage
|
|
843
|
+
|| value.stageOrdinal !== REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1
|
|
844
|
+
|| value.operation !== operation
|
|
845
|
+
|| !Number.isSafeInteger(value.attempt)
|
|
846
|
+
|| value.attempt < 1
|
|
847
|
+
|| value.status !== "invocation_started"
|
|
848
|
+
|| value.authorizationGranted !== false
|
|
849
|
+
) {
|
|
850
|
+
throw new Error("Remote deployment stage before journal is invalid.");
|
|
851
|
+
}
|
|
852
|
+
const { journalSha256, ...body } = value;
|
|
853
|
+
if (journalSha256 !== hashObject(body)) {
|
|
854
|
+
throw new Error("Remote deployment stage before journal hash drifted.");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function validateAfter(value, before, stage, operation) {
|
|
859
|
+
assertAllowedKeys(value, "Remote deployment stage after journal", [
|
|
860
|
+
"schemaVersion",
|
|
861
|
+
"bindingSha256",
|
|
862
|
+
"beforeJournalSha256",
|
|
863
|
+
"stage",
|
|
864
|
+
"operation",
|
|
865
|
+
"attempt",
|
|
866
|
+
"outcome",
|
|
867
|
+
"stageReceipt",
|
|
868
|
+
"recoveredByJournalSha256",
|
|
869
|
+
"authorizationGranted",
|
|
870
|
+
"journalSha256",
|
|
871
|
+
]);
|
|
872
|
+
const { journalSha256, ...afterBody } = value;
|
|
873
|
+
if (
|
|
874
|
+
value?.schemaVersion !== "OwlCodaRunKitRemoteStageInvocationAfterV1"
|
|
875
|
+
|| value.bindingSha256 !== normalizedBinding.bindingSha256
|
|
876
|
+
|| value.beforeJournalSha256 !== before.journalSha256
|
|
877
|
+
|| value.stage !== stage
|
|
878
|
+
|| value.operation !== operation
|
|
879
|
+
|| value.attempt !== before.attempt
|
|
880
|
+
|| !new Set(["completed", "indeterminate"]).has(value.outcome)
|
|
881
|
+
|| value.authorizationGranted !== false
|
|
882
|
+
|| journalSha256 !== hashObject(afterBody)
|
|
883
|
+
) {
|
|
884
|
+
throw new Error("Remote deployment stage after journal is invalid.");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function readValidatedInvocationHistory(stage, operation) {
|
|
889
|
+
const files = invocationFiles(root, stage, operation);
|
|
890
|
+
return files.map((entry) => {
|
|
891
|
+
const before = readJournalArtifact(
|
|
892
|
+
entry.beforePath,
|
|
893
|
+
"Remote deployment stage before journal",
|
|
894
|
+
).value;
|
|
895
|
+
validateBefore(before, stage, operation);
|
|
896
|
+
let after = null;
|
|
897
|
+
if (existsSync(entry.afterPath)) {
|
|
898
|
+
after = readJournalArtifact(
|
|
899
|
+
entry.afterPath,
|
|
900
|
+
"Remote deployment stage after journal",
|
|
901
|
+
).value;
|
|
902
|
+
validateAfter(after, before, stage, operation);
|
|
903
|
+
}
|
|
904
|
+
return { ...entry, before, after };
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function readState(stage, operation = "execute") {
|
|
909
|
+
const validated = readValidatedInvocationHistory(stage, operation);
|
|
910
|
+
if (operation === "execute") {
|
|
911
|
+
readValidatedInvocationHistory(stage, "reconcile");
|
|
912
|
+
}
|
|
913
|
+
if (validated.length === 0) return { status: "none" };
|
|
914
|
+
const latest = validated.at(-1);
|
|
915
|
+
const before = latest.before;
|
|
916
|
+
if (operation === "execute") {
|
|
917
|
+
const recoveryPrefix = `${String(
|
|
918
|
+
REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
|
|
919
|
+
).padStart(2, "0")}-${stage}-recovery-attempt-`;
|
|
920
|
+
const recoveryNames = readdirSync(root)
|
|
921
|
+
.filter(name => (
|
|
922
|
+
name.startsWith(recoveryPrefix)
|
|
923
|
+
&& name.endsWith(".json")
|
|
924
|
+
))
|
|
925
|
+
.sort();
|
|
926
|
+
if (recoveryNames.length > 0) {
|
|
927
|
+
const recovery = readJournalArtifact(
|
|
928
|
+
path.join(root, recoveryNames.at(-1)),
|
|
929
|
+
"Remote deployment stage recovery journal",
|
|
930
|
+
).value;
|
|
931
|
+
assertAllowedKeys(
|
|
932
|
+
recovery,
|
|
933
|
+
"Remote deployment stage recovery journal",
|
|
934
|
+
[
|
|
935
|
+
"schemaVersion",
|
|
936
|
+
"bindingSha256",
|
|
937
|
+
"stage",
|
|
938
|
+
"attempt",
|
|
939
|
+
"executeBeforeJournalSha256",
|
|
940
|
+
"reconciliationAfterJournalSha256",
|
|
941
|
+
"status",
|
|
942
|
+
"stageReceipt",
|
|
943
|
+
"authorizationGranted",
|
|
944
|
+
"journalSha256",
|
|
945
|
+
],
|
|
946
|
+
);
|
|
947
|
+
const { journalSha256, ...recoveryBody } = recovery;
|
|
948
|
+
if (
|
|
949
|
+
recovery?.schemaVersion
|
|
950
|
+
!== "OwlCodaRunKitRemoteStageRecoveryV1"
|
|
951
|
+
|| recovery.bindingSha256 !== normalizedBinding.bindingSha256
|
|
952
|
+
|| recovery.stage !== stage
|
|
953
|
+
|| recovery.executeBeforeJournalSha256 !== before.journalSha256
|
|
954
|
+
|| !SHA256.test(recovery.reconciliationAfterJournalSha256)
|
|
955
|
+
|| recovery.status !== "state_proven_consistent"
|
|
956
|
+
|| recovery.authorizationGranted !== false
|
|
957
|
+
|| journalSha256 !== hashObject(recoveryBody)
|
|
958
|
+
) {
|
|
959
|
+
throw new Error("Remote deployment stage recovery journal is invalid.");
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
status: "completed",
|
|
963
|
+
operation,
|
|
964
|
+
before,
|
|
965
|
+
recovery,
|
|
966
|
+
stageReceipt: structuredClone(recovery.stageReceipt),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (latest.after === null) {
|
|
971
|
+
return {
|
|
972
|
+
status: "interrupted",
|
|
973
|
+
operation,
|
|
974
|
+
before,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
const after = latest.after;
|
|
978
|
+
return {
|
|
979
|
+
status: after.outcome === "completed"
|
|
980
|
+
? "completed"
|
|
981
|
+
: "indeterminate",
|
|
982
|
+
operation,
|
|
983
|
+
before,
|
|
984
|
+
after,
|
|
985
|
+
stageReceipt: structuredClone(after.stageReceipt),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
binding: structuredClone(normalizedBinding),
|
|
991
|
+
journalRoot: root,
|
|
992
|
+
readStageState(stage) {
|
|
993
|
+
if (!REMOTE_DEPLOYMENT_STAGES.includes(stage)) {
|
|
994
|
+
throw new Error("Remote deployment journal stage is invalid.");
|
|
995
|
+
}
|
|
996
|
+
return readState(stage, "execute");
|
|
997
|
+
},
|
|
998
|
+
beginInvocation({ stage, operation }) {
|
|
999
|
+
if (
|
|
1000
|
+
!REMOTE_DEPLOYMENT_STAGES.includes(stage)
|
|
1001
|
+
|| !new Set(["execute", "reconcile"]).has(operation)
|
|
1002
|
+
) {
|
|
1003
|
+
throw new Error("Remote deployment journal invocation is invalid.");
|
|
1004
|
+
}
|
|
1005
|
+
const attempt = invocationFiles(root, stage, operation).length + 1;
|
|
1006
|
+
const body = {
|
|
1007
|
+
schemaVersion: "OwlCodaRunKitRemoteStageInvocationBeforeV1",
|
|
1008
|
+
bindingSha256: normalizedBinding.bindingSha256,
|
|
1009
|
+
deploymentId: normalizedBinding.deploymentId,
|
|
1010
|
+
deploymentLineageSha256:
|
|
1011
|
+
normalizedBinding.deploymentLineageSha256,
|
|
1012
|
+
remoteManifestSha256: normalizedBinding.remoteManifestSha256,
|
|
1013
|
+
executeRequestSha256: normalizedBinding.executeRequestSha256,
|
|
1014
|
+
adapterIdentitySha256: normalizedBinding.adapterIdentitySha256,
|
|
1015
|
+
stage,
|
|
1016
|
+
stageOrdinal: REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
|
|
1017
|
+
operation,
|
|
1018
|
+
attempt,
|
|
1019
|
+
status: "invocation_started",
|
|
1020
|
+
authorizationGranted: false,
|
|
1021
|
+
};
|
|
1022
|
+
const before = { ...body, journalSha256: hashObject(body) };
|
|
1023
|
+
writeCreateOnlyJson(
|
|
1024
|
+
path.join(root, journalFileName(stage, operation, attempt, "before")),
|
|
1025
|
+
before,
|
|
1026
|
+
);
|
|
1027
|
+
return before;
|
|
1028
|
+
},
|
|
1029
|
+
completeInvocation({
|
|
1030
|
+
stage,
|
|
1031
|
+
operation,
|
|
1032
|
+
before,
|
|
1033
|
+
outcome,
|
|
1034
|
+
stageReceipt,
|
|
1035
|
+
recoveredByJournalSha256 = null,
|
|
1036
|
+
}) {
|
|
1037
|
+
validateBefore(before, stage, operation);
|
|
1038
|
+
if (!new Set(["completed", "indeterminate"]).has(outcome)) {
|
|
1039
|
+
throw new Error("Remote deployment journal outcome is invalid.");
|
|
1040
|
+
}
|
|
1041
|
+
const body = {
|
|
1042
|
+
schemaVersion: "OwlCodaRunKitRemoteStageInvocationAfterV1",
|
|
1043
|
+
bindingSha256: normalizedBinding.bindingSha256,
|
|
1044
|
+
beforeJournalSha256: before.journalSha256,
|
|
1045
|
+
stage,
|
|
1046
|
+
operation,
|
|
1047
|
+
attempt: before.attempt,
|
|
1048
|
+
outcome,
|
|
1049
|
+
stageReceipt: structuredClone(stageReceipt),
|
|
1050
|
+
...(recoveredByJournalSha256
|
|
1051
|
+
? { recoveredByJournalSha256 }
|
|
1052
|
+
: {}),
|
|
1053
|
+
authorizationGranted: false,
|
|
1054
|
+
};
|
|
1055
|
+
const after = { ...body, journalSha256: hashObject(body) };
|
|
1056
|
+
writeCreateOnlyJson(
|
|
1057
|
+
path.join(
|
|
1058
|
+
root,
|
|
1059
|
+
journalFileName(stage, operation, before.attempt, "after"),
|
|
1060
|
+
),
|
|
1061
|
+
after,
|
|
1062
|
+
);
|
|
1063
|
+
return after;
|
|
1064
|
+
},
|
|
1065
|
+
recoverInvocation({
|
|
1066
|
+
stage,
|
|
1067
|
+
executeBefore,
|
|
1068
|
+
reconciliationAfter,
|
|
1069
|
+
stageReceipt,
|
|
1070
|
+
}) {
|
|
1071
|
+
validateBefore(executeBefore, stage, "execute");
|
|
1072
|
+
if (
|
|
1073
|
+
reconciliationAfter?.schemaVersion
|
|
1074
|
+
!== "OwlCodaRunKitRemoteStageInvocationAfterV1"
|
|
1075
|
+
|| reconciliationAfter.stage !== stage
|
|
1076
|
+
|| reconciliationAfter.operation !== "reconcile"
|
|
1077
|
+
|| reconciliationAfter.outcome !== "completed"
|
|
1078
|
+
|| !SHA256.test(reconciliationAfter.journalSha256)
|
|
1079
|
+
) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
"Remote deployment recovery requires an exact completed reconciliation journal.",
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
const recoveryPrefix = `${String(
|
|
1085
|
+
REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
|
|
1086
|
+
).padStart(2, "0")}-${stage}-recovery-attempt-`;
|
|
1087
|
+
const attempt = readdirSync(root)
|
|
1088
|
+
.filter(name => name.startsWith(recoveryPrefix))
|
|
1089
|
+
.length + 1;
|
|
1090
|
+
const body = {
|
|
1091
|
+
schemaVersion: "OwlCodaRunKitRemoteStageRecoveryV1",
|
|
1092
|
+
bindingSha256: normalizedBinding.bindingSha256,
|
|
1093
|
+
stage,
|
|
1094
|
+
attempt,
|
|
1095
|
+
executeBeforeJournalSha256: executeBefore.journalSha256,
|
|
1096
|
+
reconciliationAfterJournalSha256:
|
|
1097
|
+
reconciliationAfter.journalSha256,
|
|
1098
|
+
status: "state_proven_consistent",
|
|
1099
|
+
stageReceipt: structuredClone(stageReceipt),
|
|
1100
|
+
authorizationGranted: false,
|
|
1101
|
+
};
|
|
1102
|
+
const recovery = { ...body, journalSha256: hashObject(body) };
|
|
1103
|
+
writeCreateOnlyJson(
|
|
1104
|
+
path.join(
|
|
1105
|
+
root,
|
|
1106
|
+
`${recoveryPrefix}${String(attempt).padStart(3, "0")}.json`,
|
|
1107
|
+
),
|
|
1108
|
+
recovery,
|
|
1109
|
+
);
|
|
1110
|
+
return recovery;
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function failure(stage, failureCode, evidenceSha256 = null) {
|
|
1116
|
+
return {
|
|
1117
|
+
ok: false,
|
|
1118
|
+
stageReceipt: {
|
|
1119
|
+
stage,
|
|
1120
|
+
status: "failed",
|
|
1121
|
+
failureCode,
|
|
1122
|
+
...(evidenceSha256 ? { evidenceSha256 } : {}),
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function normalizeStageResult(stage, result, manifest) {
|
|
1128
|
+
if (!isRecord(result)) return failure(stage, "stage_result_invalid");
|
|
1129
|
+
if (hasSecretShapedField(result)) return failure(stage, "secret_material_forbidden");
|
|
1130
|
+
if (result.status === "indeterminate") {
|
|
1131
|
+
try {
|
|
1132
|
+
assertAllowedKeys(result, `${stage} indeterminate result`, [
|
|
1133
|
+
"status",
|
|
1134
|
+
"failureCode",
|
|
1135
|
+
"evidenceSha256",
|
|
1136
|
+
]);
|
|
1137
|
+
safeIdentifier(result.failureCode, `${stage} failureCode`);
|
|
1138
|
+
if (result.evidenceSha256 !== undefined) {
|
|
1139
|
+
assertHash(result.evidenceSha256, `${stage} failure evidence`);
|
|
1140
|
+
}
|
|
1141
|
+
return {
|
|
1142
|
+
...failure(stage, result.failureCode, result.evidenceSha256),
|
|
1143
|
+
indeterminate: true,
|
|
1144
|
+
};
|
|
1145
|
+
} catch {
|
|
1146
|
+
return failure(stage, "stage_result_invalid");
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (result.status === "failed") {
|
|
1150
|
+
try {
|
|
1151
|
+
assertAllowedKeys(result, `${stage} result`, [
|
|
1152
|
+
"status",
|
|
1153
|
+
"failureCode",
|
|
1154
|
+
"evidenceSha256",
|
|
1155
|
+
]);
|
|
1156
|
+
safeIdentifier(result.failureCode, `${stage} failureCode`);
|
|
1157
|
+
if (result.evidenceSha256 !== undefined) {
|
|
1158
|
+
assertHash(result.evidenceSha256, `${stage} failure evidence`);
|
|
1159
|
+
}
|
|
1160
|
+
return failure(stage, result.failureCode, result.evidenceSha256);
|
|
1161
|
+
} catch {
|
|
1162
|
+
return failure(stage, "stage_result_invalid");
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
try {
|
|
1166
|
+
if (stage === "identity_preflight") {
|
|
1167
|
+
assertAllowedKeys(result, "identity_preflight result", [
|
|
1168
|
+
"status",
|
|
1169
|
+
"hostKeySha256",
|
|
1170
|
+
"machineIdentitySha256",
|
|
1171
|
+
"remoteHelper",
|
|
1172
|
+
]);
|
|
1173
|
+
if (result.status !== "passed") return failure(stage, "target_identity_preflight_failed");
|
|
1174
|
+
assertHash(result.hostKeySha256, "Observed remote host key");
|
|
1175
|
+
assertHash(result.machineIdentitySha256, "Observed remote machine identity");
|
|
1176
|
+
if (result.hostKeySha256 !== manifest.target.hostKeySha256
|
|
1177
|
+
|| result.machineIdentitySha256 !== manifest.target.machineIdentitySha256) {
|
|
1178
|
+
return failure(stage, "target_identity_mismatch");
|
|
1179
|
+
}
|
|
1180
|
+
const expectedRemoteHelper = manifest.adapter.kind === "builtin_ssh"
|
|
1181
|
+
? manifest.adapter.remoteHelper
|
|
1182
|
+
: null;
|
|
1183
|
+
if (
|
|
1184
|
+
expectedRemoteHelper !== null
|
|
1185
|
+
&& (
|
|
1186
|
+
!isRecord(result.remoteHelper)
|
|
1187
|
+
|| canonicalJson(result.remoteHelper)
|
|
1188
|
+
!== canonicalJson(expectedRemoteHelper)
|
|
1189
|
+
)
|
|
1190
|
+
) {
|
|
1191
|
+
return failure(stage, "remote_helper_capability_mismatch");
|
|
1192
|
+
}
|
|
1193
|
+
if (
|
|
1194
|
+
expectedRemoteHelper === null
|
|
1195
|
+
&& result.remoteHelper !== undefined
|
|
1196
|
+
) {
|
|
1197
|
+
return failure(stage, "stage_result_invalid");
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
ok: true,
|
|
1201
|
+
stageReceipt: {
|
|
1202
|
+
stage,
|
|
1203
|
+
status: "passed",
|
|
1204
|
+
hostKeySha256: result.hostKeySha256,
|
|
1205
|
+
machineIdentitySha256: result.machineIdentitySha256,
|
|
1206
|
+
...(expectedRemoteHelper
|
|
1207
|
+
? { remoteHelper: structuredClone(expectedRemoteHelper) }
|
|
1208
|
+
: {}),
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
if (stage === "upload") {
|
|
1213
|
+
assertAllowedKeys(result, "upload result", [
|
|
1214
|
+
"status",
|
|
1215
|
+
"remotePath",
|
|
1216
|
+
"sha256",
|
|
1217
|
+
"size",
|
|
1218
|
+
]);
|
|
1219
|
+
if (!new Set(["created", "already_present"]).has(result.status)
|
|
1220
|
+
|| result.remotePath !== manifest.upload.remotePath
|
|
1221
|
+
|| result.sha256 !== manifest.artifact.sha256
|
|
1222
|
+
|| result.size !== manifest.artifact.size) {
|
|
1223
|
+
return failure(stage, "create_only_upload_conflict");
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
ok: true,
|
|
1227
|
+
stageReceipt: {
|
|
1228
|
+
stage,
|
|
1229
|
+
status: result.status,
|
|
1230
|
+
remotePath: result.remotePath,
|
|
1231
|
+
sha256: result.sha256,
|
|
1232
|
+
size: result.size,
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
if (stage === "verify_remote_hashes") {
|
|
1237
|
+
assertAllowedKeys(result, "verify_remote_hashes result", [
|
|
1238
|
+
"status",
|
|
1239
|
+
"remotePath",
|
|
1240
|
+
"sha256",
|
|
1241
|
+
"size",
|
|
1242
|
+
]);
|
|
1243
|
+
if (result.status !== "passed"
|
|
1244
|
+
|| result.remotePath !== manifest.upload.remotePath
|
|
1245
|
+
|| result.sha256 !== manifest.artifact.sha256
|
|
1246
|
+
|| result.size !== manifest.artifact.size) {
|
|
1247
|
+
return failure(stage, "remote_artifact_hash_mismatch");
|
|
1248
|
+
}
|
|
1249
|
+
return {
|
|
1250
|
+
ok: true,
|
|
1251
|
+
stageReceipt: {
|
|
1252
|
+
stage,
|
|
1253
|
+
status: "passed",
|
|
1254
|
+
remotePath: result.remotePath,
|
|
1255
|
+
sha256: result.sha256,
|
|
1256
|
+
size: result.size,
|
|
1257
|
+
},
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
assertAllowedKeys(result, `${stage} result`, ["status", "evidenceSha256"]);
|
|
1261
|
+
if (result.status !== "passed") return failure(stage, `${stage}_failed`);
|
|
1262
|
+
assertHash(result.evidenceSha256, `${stage} evidence`);
|
|
1263
|
+
return {
|
|
1264
|
+
ok: true,
|
|
1265
|
+
stageReceipt: {
|
|
1266
|
+
stage,
|
|
1267
|
+
status: "passed",
|
|
1268
|
+
evidenceSha256: result.evidenceSha256,
|
|
1269
|
+
},
|
|
1270
|
+
};
|
|
1271
|
+
} catch {
|
|
1272
|
+
return failure(stage, "stage_result_invalid");
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function resultArtifact(manifest, adapterIdentity, state) {
|
|
1277
|
+
const status = state.reconciliationRequired
|
|
1278
|
+
? "reconciliation_required"
|
|
1279
|
+
: state.failureCode
|
|
1280
|
+
? "failed"
|
|
1281
|
+
: "deployed";
|
|
1282
|
+
const body = {
|
|
1283
|
+
schemaVersion: "OwlCodaRunKitRemoteDeploymentResultV1",
|
|
1284
|
+
deploymentId: manifest.deploymentId,
|
|
1285
|
+
deploymentLineageSha256: manifest.deploymentLineageSha256,
|
|
1286
|
+
mode: manifest.mode,
|
|
1287
|
+
target: manifest.target,
|
|
1288
|
+
adapter: adapterIdentity,
|
|
1289
|
+
artifact: manifest.artifact,
|
|
1290
|
+
status,
|
|
1291
|
+
completedStages: state.completedStages,
|
|
1292
|
+
stoppedAtStage: state.stoppedAtStage,
|
|
1293
|
+
...(state.failureCode ? { failureCode: state.failureCode } : {}),
|
|
1294
|
+
stageReceipts: state.stageReceipts,
|
|
1295
|
+
deletionAllowlistSha256: hashObject(manifest.deletionAllowlist),
|
|
1296
|
+
authorizationGranted: false,
|
|
1297
|
+
};
|
|
1298
|
+
return { ...body, resultSha256: hashObject(body) };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function remoteStageInput(stage, manifest, permissions) {
|
|
1302
|
+
return {
|
|
1303
|
+
stage,
|
|
1304
|
+
deploymentId: manifest.deploymentId,
|
|
1305
|
+
deploymentLineageSha256: manifest.deploymentLineageSha256,
|
|
1306
|
+
mode: manifest.mode,
|
|
1307
|
+
...(manifest.ownerDecision === undefined
|
|
1308
|
+
? {}
|
|
1309
|
+
: { ownerDecision: structuredClone(manifest.ownerDecision) }),
|
|
1310
|
+
...(manifest.serviceActivation === undefined
|
|
1311
|
+
? {}
|
|
1312
|
+
: { serviceActivation: manifest.serviceActivation }),
|
|
1313
|
+
...(manifest.baselineCut === undefined
|
|
1314
|
+
? {}
|
|
1315
|
+
: { baselineCut: manifest.baselineCut }),
|
|
1316
|
+
target: structuredClone(manifest.target),
|
|
1317
|
+
artifact: structuredClone(manifest.artifact),
|
|
1318
|
+
upload: structuredClone(manifest.upload),
|
|
1319
|
+
priorDeployment: structuredClone(manifest.priorDeployment),
|
|
1320
|
+
expectedRemoteFiles: structuredClone(manifest.expectedRemoteFiles),
|
|
1321
|
+
deletionAllowlist: structuredClone(manifest.deletionAllowlist),
|
|
1322
|
+
permissions: structuredClone(permissions),
|
|
1323
|
+
credentialRef: manifest.credentialRef,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function normalizePersistedStageReceipt(stage, receipt, manifest) {
|
|
1328
|
+
if (!isRecord(receipt) || receipt.stage !== stage) {
|
|
1329
|
+
return failure(stage, "stage_journal_receipt_invalid");
|
|
1330
|
+
}
|
|
1331
|
+
const { stage: _stage, ...rawResult } = receipt;
|
|
1332
|
+
return normalizeStageResult(stage, rawResult, manifest);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function reconciliationRequiredArtifact({
|
|
1336
|
+
manifest,
|
|
1337
|
+
adapterIdentity,
|
|
1338
|
+
completedStages,
|
|
1339
|
+
stageReceipts,
|
|
1340
|
+
stage,
|
|
1341
|
+
}) {
|
|
1342
|
+
return resultArtifact(manifest, adapterIdentity, {
|
|
1343
|
+
completedStages,
|
|
1344
|
+
stoppedAtStage: stage,
|
|
1345
|
+
failureCode: "remote_stage_reconciliation_required",
|
|
1346
|
+
reconciliationRequired: true,
|
|
1347
|
+
stageReceipts: [
|
|
1348
|
+
...stageReceipts,
|
|
1349
|
+
{
|
|
1350
|
+
stage,
|
|
1351
|
+
status: "failed",
|
|
1352
|
+
failureCode: "remote_stage_reconciliation_required",
|
|
1353
|
+
},
|
|
1354
|
+
],
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export async function executeRemoteDeployment({
|
|
1359
|
+
manifest,
|
|
1360
|
+
adapter,
|
|
1361
|
+
permissions,
|
|
1362
|
+
beforeStageGuard,
|
|
1363
|
+
stageJournal = null,
|
|
1364
|
+
}) {
|
|
1365
|
+
const validation = validateRemoteDeploymentManifest(manifest);
|
|
1366
|
+
const normalized = validation.normalized;
|
|
1367
|
+
assertAllowedKeys(permissions, "Deployment permissions", [
|
|
1368
|
+
"deploy",
|
|
1369
|
+
"destructive",
|
|
1370
|
+
]);
|
|
1371
|
+
if (
|
|
1372
|
+
permissions.deploy !== true
|
|
1373
|
+
|| typeof permissions.destructive !== "boolean"
|
|
1374
|
+
) {
|
|
1375
|
+
throw new Error("Deployment permissions must explicitly grant deploy and state destructive permission.");
|
|
1376
|
+
}
|
|
1377
|
+
if (
|
|
1378
|
+
permissions.destructive === false
|
|
1379
|
+
&& normalized.deletionAllowlist.length > 0
|
|
1380
|
+
) {
|
|
1381
|
+
throw new Error("destructive=false forbids every declared remote deletion.");
|
|
1382
|
+
}
|
|
1383
|
+
if (!isRecord(adapter) || typeof adapter.runStage !== "function") {
|
|
1384
|
+
throw new Error("Remote deployment requires an injected typed adapter.");
|
|
1385
|
+
}
|
|
1386
|
+
if (typeof beforeStageGuard !== "function") {
|
|
1387
|
+
throw new Error("Remote deployment requires a control guard.");
|
|
1388
|
+
}
|
|
1389
|
+
const adapterIdentity = validateAdapterIdentityFields(adapter.identity);
|
|
1390
|
+
if (!identitiesEqual(adapterIdentity, adapterRuntimeIdentity(normalized.adapter))) {
|
|
1391
|
+
throw new Error("Injected remote adapter identity does not match the manifest.");
|
|
1392
|
+
}
|
|
1393
|
+
if (stageJournal !== null) {
|
|
1394
|
+
if (
|
|
1395
|
+
!isRecord(stageJournal)
|
|
1396
|
+
|| typeof stageJournal.readStageState !== "function"
|
|
1397
|
+
|| typeof stageJournal.beginInvocation !== "function"
|
|
1398
|
+
|| typeof stageJournal.completeInvocation !== "function"
|
|
1399
|
+
|| typeof stageJournal.recoverInvocation !== "function"
|
|
1400
|
+
|| stageJournal.binding?.deploymentId !== normalized.deploymentId
|
|
1401
|
+
|| stageJournal.binding?.deploymentLineageSha256
|
|
1402
|
+
!== normalized.deploymentLineageSha256
|
|
1403
|
+
|| !identitiesEqual(
|
|
1404
|
+
stageJournal.binding?.adapterIdentity,
|
|
1405
|
+
adapterIdentity,
|
|
1406
|
+
)
|
|
1407
|
+
) {
|
|
1408
|
+
throw new Error(
|
|
1409
|
+
"Remote deployment stage journal does not bind the exact deployment and adapter.",
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const completedStages = [];
|
|
1414
|
+
const stageReceipts = [];
|
|
1415
|
+
for (const stage of REMOTE_DEPLOYMENT_STAGES) {
|
|
1416
|
+
try {
|
|
1417
|
+
await beforeStageGuard({ stage });
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
const failureCode = error?.code === "owner_decision_superseded"
|
|
1420
|
+
? "owner_decision_superseded"
|
|
1421
|
+
: "deployment_control_guard_failed";
|
|
1422
|
+
return resultArtifact(normalized, adapterIdentity, {
|
|
1423
|
+
completedStages,
|
|
1424
|
+
stoppedAtStage: stage,
|
|
1425
|
+
failureCode,
|
|
1426
|
+
stageReceipts: [
|
|
1427
|
+
...stageReceipts,
|
|
1428
|
+
{
|
|
1429
|
+
stage,
|
|
1430
|
+
status: "failed",
|
|
1431
|
+
failureCode,
|
|
1432
|
+
},
|
|
1433
|
+
],
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const priorState = stageJournal?.readStageState(stage) ?? {
|
|
1438
|
+
status: "none",
|
|
1439
|
+
};
|
|
1440
|
+
if (priorState.status === "completed") {
|
|
1441
|
+
const persisted = normalizePersistedStageReceipt(
|
|
1442
|
+
stage,
|
|
1443
|
+
priorState.stageReceipt,
|
|
1444
|
+
normalized,
|
|
1445
|
+
);
|
|
1446
|
+
stageReceipts.push(persisted.stageReceipt);
|
|
1447
|
+
if (!persisted.ok) {
|
|
1448
|
+
return resultArtifact(normalized, adapterIdentity, {
|
|
1449
|
+
completedStages,
|
|
1450
|
+
stoppedAtStage: stage,
|
|
1451
|
+
failureCode: persisted.stageReceipt.failureCode,
|
|
1452
|
+
stageReceipts,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
completedStages.push(stage);
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const stageInput = remoteStageInput(stage, normalized, permissions);
|
|
1460
|
+
if (
|
|
1461
|
+
priorState.status === "interrupted"
|
|
1462
|
+
|| priorState.status === "indeterminate"
|
|
1463
|
+
) {
|
|
1464
|
+
let reconciliation = null;
|
|
1465
|
+
let reconciliationAfter = null;
|
|
1466
|
+
if (typeof adapter.reconcileStage === "function") {
|
|
1467
|
+
const reconciliationBefore = stageJournal.beginInvocation({
|
|
1468
|
+
stage,
|
|
1469
|
+
operation: "reconcile",
|
|
1470
|
+
});
|
|
1471
|
+
try {
|
|
1472
|
+
const rawReconciliation = await adapter.reconcileStage(stageInput);
|
|
1473
|
+
reconciliation = normalizeStageResult(
|
|
1474
|
+
stage,
|
|
1475
|
+
rawReconciliation,
|
|
1476
|
+
normalized,
|
|
1477
|
+
);
|
|
1478
|
+
reconciliationAfter = stageJournal.completeInvocation({
|
|
1479
|
+
stage,
|
|
1480
|
+
operation: "reconcile",
|
|
1481
|
+
before: reconciliationBefore,
|
|
1482
|
+
outcome: "completed",
|
|
1483
|
+
stageReceipt: reconciliation.stageReceipt,
|
|
1484
|
+
});
|
|
1485
|
+
} catch {
|
|
1486
|
+
reconciliation = null;
|
|
1487
|
+
reconciliationAfter = stageJournal.completeInvocation({
|
|
1488
|
+
stage,
|
|
1489
|
+
operation: "reconcile",
|
|
1490
|
+
before: reconciliationBefore,
|
|
1491
|
+
outcome: "indeterminate",
|
|
1492
|
+
stageReceipt: {
|
|
1493
|
+
stage,
|
|
1494
|
+
status: "failed",
|
|
1495
|
+
failureCode: "reconciliation_probe_threw",
|
|
1496
|
+
},
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
if (reconciliation?.ok) {
|
|
1501
|
+
stageJournal.recoverInvocation({
|
|
1502
|
+
stage,
|
|
1503
|
+
executeBefore: priorState.before,
|
|
1504
|
+
reconciliationAfter,
|
|
1505
|
+
stageReceipt: reconciliation.stageReceipt,
|
|
1506
|
+
});
|
|
1507
|
+
stageReceipts.push(reconciliation.stageReceipt);
|
|
1508
|
+
completedStages.push(stage);
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
if (!IDEMPOTENT_REMOTE_STAGES.has(stage)) {
|
|
1512
|
+
return reconciliationRequiredArtifact({
|
|
1513
|
+
manifest: normalized,
|
|
1514
|
+
adapterIdentity,
|
|
1515
|
+
completedStages,
|
|
1516
|
+
stageReceipts,
|
|
1517
|
+
stage,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const executionBefore = stageJournal?.beginInvocation({
|
|
1523
|
+
stage,
|
|
1524
|
+
operation: "execute",
|
|
1525
|
+
}) ?? null;
|
|
1526
|
+
let rawResult;
|
|
1527
|
+
try {
|
|
1528
|
+
rawResult = await adapter.runStage(stageInput);
|
|
1529
|
+
} catch {
|
|
1530
|
+
if (stageJournal) {
|
|
1531
|
+
stageJournal.completeInvocation({
|
|
1532
|
+
stage,
|
|
1533
|
+
operation: "execute",
|
|
1534
|
+
before: executionBefore,
|
|
1535
|
+
outcome: "indeterminate",
|
|
1536
|
+
stageReceipt: {
|
|
1537
|
+
stage,
|
|
1538
|
+
status: "failed",
|
|
1539
|
+
failureCode: "adapter_stage_threw",
|
|
1540
|
+
},
|
|
1541
|
+
});
|
|
1542
|
+
return reconciliationRequiredArtifact({
|
|
1543
|
+
manifest: normalized,
|
|
1544
|
+
adapterIdentity,
|
|
1545
|
+
completedStages,
|
|
1546
|
+
stageReceipts,
|
|
1547
|
+
stage,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
return resultArtifact(normalized, adapterIdentity, {
|
|
1551
|
+
completedStages,
|
|
1552
|
+
stoppedAtStage: stage,
|
|
1553
|
+
failureCode: "adapter_stage_threw",
|
|
1554
|
+
stageReceipts: [
|
|
1555
|
+
...stageReceipts,
|
|
1556
|
+
{ stage, status: "failed", failureCode: "adapter_stage_threw" },
|
|
1557
|
+
],
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
const checked = normalizeStageResult(stage, rawResult, normalized);
|
|
1561
|
+
if (stageJournal) {
|
|
1562
|
+
stageJournal.completeInvocation({
|
|
1563
|
+
stage,
|
|
1564
|
+
operation: "execute",
|
|
1565
|
+
before: executionBefore,
|
|
1566
|
+
outcome: checked.indeterminate ? "indeterminate" : "completed",
|
|
1567
|
+
stageReceipt: checked.stageReceipt,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
if (checked.indeterminate && stageJournal) {
|
|
1571
|
+
return reconciliationRequiredArtifact({
|
|
1572
|
+
manifest: normalized,
|
|
1573
|
+
adapterIdentity,
|
|
1574
|
+
completedStages,
|
|
1575
|
+
stageReceipts,
|
|
1576
|
+
stage,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
stageReceipts.push(checked.stageReceipt);
|
|
1580
|
+
if (!checked.ok) {
|
|
1581
|
+
return resultArtifact(normalized, adapterIdentity, {
|
|
1582
|
+
completedStages,
|
|
1583
|
+
stoppedAtStage: stage,
|
|
1584
|
+
failureCode: checked.stageReceipt.failureCode,
|
|
1585
|
+
stageReceipts,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
completedStages.push(stage);
|
|
1589
|
+
}
|
|
1590
|
+
return resultArtifact(normalized, adapterIdentity, {
|
|
1591
|
+
completedStages,
|
|
1592
|
+
stoppedAtStage: null,
|
|
1593
|
+
failureCode: null,
|
|
1594
|
+
stageReceipts,
|
|
1595
|
+
});
|
|
1596
|
+
}
|