@kungfu-tech/buildchain 2.3.1 → 2.4.0-alpha.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.
@@ -0,0 +1,1032 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { loadBuildchainConfig, validateBuildchainConfig } from "../packages/core/buildchain-config.js";
6
+
7
+ const PLAN_CONTRACT = "kungfu-buildchain-infra-contract-plan";
8
+ const ARTIFACT_CONTRACT = "kungfu-buildchain-infra-contract";
9
+ const PROPAGATION_CONTRACT = "kungfu-buildchain-infra-contract-propagation-plan";
10
+ const PROPAGATION_APPLY_CONTRACT = "kungfu-buildchain-infra-contract-propagation-apply";
11
+ const APPLY_CONTRACT = "kungfu-buildchain-infra-contract-apply";
12
+ const EVIDENCE_BUNDLE_CONTRACT = "kungfu-buildchain-infra-contract-evidence-bundle";
13
+ const EVIDENCE_BUNDLE_VERIFICATION_CONTRACT = "kungfu-buildchain-infra-contract-evidence-bundle-verification";
14
+
15
+ const STATIC_CAPABILITIES = Object.freeze({
16
+ "manual-observed": { validate: true, plan: false, apply: false, observe: true },
17
+ "aws-cloudformation": { validate: true, plan: true, apply: true, observe: true },
18
+ terraform: { validate: true, plan: true, apply: true, observe: true },
19
+ opentofu: { validate: true, plan: true, apply: true, observe: true },
20
+ pulumi: { validate: true, plan: true, apply: true, observe: true },
21
+ "aws-cdk": { validate: true, plan: true, apply: true, observe: true },
22
+ "aws-cli": { validate: true, plan: true, apply: true, observe: true },
23
+ });
24
+
25
+ const STATIC_ADAPTER_COMMANDS = Object.freeze({
26
+ "aws-cloudformation": {
27
+ validate: ({ desiredFile }) => `aws cloudformation validate-template --template-body file://${desiredFile}`,
28
+ plan: ({ desiredFile }) => `aws cloudformation create-change-set --stack-name <stack-name> --change-set-name <change-set-name> --change-set-type UPDATE --template-body file://${desiredFile}`,
29
+ apply: () => "aws cloudformation execute-change-set --stack-name <stack-name> --change-set-name <change-set-name>",
30
+ observe: () => "aws cloudformation describe-stacks --stack-name <stack-name> --query 'Stacks[0].Outputs' --output json",
31
+ },
32
+ terraform: {
33
+ validate: () => "terraform validate -no-color",
34
+ plan: () => "terraform plan -input=false -out=.buildchain/infra-contract/terraform.tfplan",
35
+ apply: () => "terraform apply -input=false .buildchain/infra-contract/terraform.tfplan",
36
+ observe: () => "terraform output -json",
37
+ },
38
+ opentofu: {
39
+ validate: () => "tofu validate -no-color",
40
+ plan: () => "tofu plan -input=false -out=.buildchain/infra-contract/opentofu.tfplan",
41
+ apply: () => "tofu apply -input=false .buildchain/infra-contract/opentofu.tfplan",
42
+ observe: () => "tofu output -json",
43
+ },
44
+ pulumi: {
45
+ validate: () => "pulumi preview --json",
46
+ plan: () => "pulumi preview --json",
47
+ apply: () => "pulumi up --yes --json",
48
+ observe: () => "pulumi stack output --json",
49
+ },
50
+ "aws-cdk": {
51
+ validate: () => "npx cdk synth",
52
+ plan: () => "npx cdk diff",
53
+ apply: () => "npx cdk deploy --require-approval never",
54
+ observe: () => "aws cloudformation describe-stacks --stack-name <cdk-stack-name> --query 'Stacks[0].Outputs' --output json",
55
+ },
56
+ "aws-cli": {
57
+ validate: () => "aws <service> <validate-operation> --cli-input-json file://<desired-file>",
58
+ plan: () => "aws <service> <plan-or-dry-run-operation> --cli-input-json file://<desired-file>",
59
+ apply: () => "aws <service> <apply-operation> --cli-input-json file://<approved-plan>",
60
+ observe: () => "aws <service> <describe-operation> --output json",
61
+ },
62
+ });
63
+
64
+ function assertInfraContractConfig(loadedConfig) {
65
+ if (loadedConfig?.config?.project?.type !== "infra-contract") {
66
+ throw new Error('buildchain.toml project.type must be "infra-contract"');
67
+ }
68
+ return loadedConfig.config;
69
+ }
70
+
71
+ function assertSha(value, label) {
72
+ const sha = String(value || "").trim();
73
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
74
+ throw new Error(`${label} must be a 40-character Git SHA`);
75
+ }
76
+ return sha.toLowerCase();
77
+ }
78
+
79
+ function toPosix(value) {
80
+ return String(value || "").split(path.sep).join("/");
81
+ }
82
+
83
+ function sha256Buffer(value) {
84
+ const hash = crypto.createHash("sha256");
85
+ hash.update(value);
86
+ return hash.digest("hex");
87
+ }
88
+
89
+ function runCommand(command, args, options = {}) {
90
+ const result = spawnSync(command, args, {
91
+ cwd: options.cwd || process.cwd(),
92
+ input: options.input,
93
+ encoding: "utf8",
94
+ });
95
+ return {
96
+ command,
97
+ args,
98
+ cwd: options.cwd || process.cwd(),
99
+ status: result.status ?? 1,
100
+ stdout: result.stdout || "",
101
+ stderr: result.stderr || result.error?.message || "",
102
+ };
103
+ }
104
+
105
+ function runShellCommand(command, options = {}) {
106
+ const shell = process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : (process.env.SHELL || "/bin/sh");
107
+ const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-c", command];
108
+ return runCommand(shell, args, options);
109
+ }
110
+
111
+ function assertCommandSuccess(result, label) {
112
+ if (result.status !== 0) {
113
+ throw new Error(`${label} failed: ${result.stderr || result.stdout || `exit ${result.status}`}`);
114
+ }
115
+ return result;
116
+ }
117
+
118
+ function parseOptionalJson(value) {
119
+ const trimmed = String(value || "").trim();
120
+ if (!trimmed) {
121
+ return undefined;
122
+ }
123
+ try {
124
+ return JSON.parse(trimmed);
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ function redactCommandText(value) {
131
+ return String(value || "").replace(/(token|secret|password|passwd|key)=\S+/gi, "$1=<redacted>");
132
+ }
133
+
134
+ function adapterCommandTemplate({ config, stage }) {
135
+ const configuredCommand = config.infra.commands?.[stage];
136
+ if (configuredCommand) {
137
+ return {
138
+ command: configuredCommand,
139
+ commandSource: "configured",
140
+ executable: true,
141
+ };
142
+ }
143
+ const adapterCommands = STATIC_ADAPTER_COMMANDS[config.infra.adapter];
144
+ const commandFactory = adapterCommands?.[stage];
145
+ if (!commandFactory) {
146
+ return null;
147
+ }
148
+ return {
149
+ command: commandFactory({
150
+ desiredFile: config.infra.desired[0] || "<desired-file>",
151
+ contractFile: config.infra.contract[0] || "<contract-file>",
152
+ }),
153
+ commandSource: "builtin-plan",
154
+ executable: false,
155
+ };
156
+ }
157
+
158
+ function collectAdapterEvidence({ cwd, config, stages, executeAdapterCommands, runner = runShellCommand }) {
159
+ return stages
160
+ .map((stage) => [stage, adapterCommandTemplate({ config, stage })])
161
+ .filter(([, template]) => template)
162
+ .map(([stageName, template]) => {
163
+ const command = template.command;
164
+ const willExecute = Boolean(executeAdapterCommands && template.executable);
165
+ const base = {
166
+ stage: stageName,
167
+ adapter: config.infra.adapter,
168
+ commandSource: template.commandSource,
169
+ command: redactCommandText(command),
170
+ executable: template.executable,
171
+ executed: willExecute,
172
+ status: willExecute ? "pending" : "planned",
173
+ exitCode: null,
174
+ stdout: "",
175
+ stderr: "",
176
+ output: undefined,
177
+ };
178
+ if (!willExecute) {
179
+ return base;
180
+ }
181
+ const result = runner(command, { cwd });
182
+ return {
183
+ ...base,
184
+ status: result.status === 0 ? "passed" : "failed",
185
+ exitCode: result.status,
186
+ stdout: result.stdout,
187
+ stderr: result.stderr,
188
+ output: parseOptionalJson(result.stdout),
189
+ };
190
+ });
191
+ }
192
+
193
+ function assertAdapterEvidencePassed(evidence) {
194
+ const failed = evidence.find((entry) => entry.executed && entry.status !== "passed");
195
+ if (failed) {
196
+ throw new Error(`infra adapter ${failed.stage} command failed: ${failed.stderr || failed.stdout || `exit ${failed.exitCode}`}`);
197
+ }
198
+ }
199
+
200
+ function stableJson(value) {
201
+ return JSON.stringify(sortJson(value), null, 2);
202
+ }
203
+
204
+ function sortJson(value) {
205
+ if (Array.isArray(value)) {
206
+ return value.map(sortJson);
207
+ }
208
+ if (value && typeof value === "object") {
209
+ return Object.fromEntries(
210
+ Object.keys(value)
211
+ .sort()
212
+ .map((key) => [key, sortJson(value[key])]),
213
+ );
214
+ }
215
+ return value;
216
+ }
217
+
218
+ function assertSafeInfraPath(rel) {
219
+ const normalized = toPosix(rel);
220
+ if (normalized.startsWith("../") || path.isAbsolute(normalized)) {
221
+ throw new Error(`infra-contract path must stay inside the repository: ${rel}`);
222
+ }
223
+ if (/\.(tfstate|tfstate\.backup)$/i.test(normalized)) {
224
+ throw new Error(`infra-contract must not read Terraform/OpenTofu state files: ${rel}`);
225
+ }
226
+ if (/pulumi\..*\.json$/i.test(path.basename(normalized))) {
227
+ throw new Error(`infra-contract must not read Pulumi state or secrets files: ${rel}`);
228
+ }
229
+ return normalized;
230
+ }
231
+
232
+ function readFileRecord(cwd, rel) {
233
+ const safeRel = assertSafeInfraPath(rel);
234
+ const filePath = path.join(cwd, safeRel);
235
+ if (!fs.existsSync(filePath)) {
236
+ throw new Error(`infra-contract file does not exist: ${safeRel}`);
237
+ }
238
+ const stat = fs.statSync(filePath);
239
+ if (!stat.isFile()) {
240
+ throw new Error(`infra-contract path must be a file: ${safeRel}`);
241
+ }
242
+ const source = fs.readFileSync(filePath);
243
+ let json;
244
+ if (safeRel.endsWith(".json")) {
245
+ json = JSON.parse(source.toString("utf8"));
246
+ }
247
+ return {
248
+ path: safeRel,
249
+ size: stat.size,
250
+ sha256: sha256Buffer(source),
251
+ json,
252
+ };
253
+ }
254
+
255
+ function configuredFileRecords(cwd, entries) {
256
+ return entries.map((entry) => readFileRecord(cwd, entry));
257
+ }
258
+
259
+ function adapterCapabilities(config) {
260
+ if (config.infra.adapter !== "custom-command") {
261
+ return STATIC_CAPABILITIES[config.infra.adapter];
262
+ }
263
+ const commands = config.infra.commands || {};
264
+ return {
265
+ validate: Boolean(commands.validate),
266
+ plan: Boolean(commands.plan),
267
+ apply: Boolean(commands.apply),
268
+ observe: Boolean(commands.observe),
269
+ };
270
+ }
271
+
272
+ function stageStatuses({ config, capabilities, planHash = "" }) {
273
+ return {
274
+ desired: { status: "declared" },
275
+ validate: { status: capabilities.validate ? "ready" : "unsupported" },
276
+ plan: { status: capabilities.plan ? "ready" : "static-contract" },
277
+ approval: {
278
+ status: config.infra.applyMode === "disabled" ? "not-required" : "required",
279
+ mode: config.infra.applyMode,
280
+ },
281
+ apply: {
282
+ status: config.infra.applyMode === "disabled" ? "disabled" : (capabilities.apply ? "gated" : "unsupported"),
283
+ },
284
+ observe: { status: capabilities.observe ? "ready" : "unsupported" },
285
+ contract: { status: planHash ? "ready" : "planned" },
286
+ propagate: { status: config.consumers.length > 0 ? "ready" : "missing-consumers" },
287
+ };
288
+ }
289
+
290
+ function createPlanInputFingerprint({ config, sourceSha, desiredFiles, contractFiles, consumerContracts, capabilities }) {
291
+ return sha256Buffer(stableJson({
292
+ project: {
293
+ name: config.project.name || "",
294
+ type: config.project.type,
295
+ },
296
+ sourceSha,
297
+ adapter: config.infra.adapter,
298
+ adoptionMode: config.infra.adoptionMode,
299
+ applyMode: config.infra.applyMode,
300
+ environment: config.infra.environment,
301
+ identityRef: config.infra.identityRef,
302
+ desiredFiles: desiredFiles.map(({ json, ...entry }) => entry),
303
+ contractFiles: contractFiles.map(({ json, ...entry }) => entry),
304
+ consumers: consumerContracts.map((consumer) => ({
305
+ repo: consumer.repo,
306
+ path: consumer.path,
307
+ source: consumer.source,
308
+ branch: consumer.branch,
309
+ sourceSha256: consumer.sourceFile.sha256,
310
+ })),
311
+ adapterCapabilities: capabilities,
312
+ }));
313
+ }
314
+
315
+ function assertInfraContractPlan(plan) {
316
+ if (!plan || typeof plan !== "object" || plan.contract !== PLAN_CONTRACT) {
317
+ throw new Error("infra-contract apply requires a saved infra-contract plan");
318
+ }
319
+ if (!plan.planHash || typeof plan.planHash !== "string") {
320
+ throw new Error("infra-contract apply plan is missing planHash");
321
+ }
322
+ return plan;
323
+ }
324
+
325
+ function assertInfraContractPropagationPlan(plan) {
326
+ if (!plan || typeof plan !== "object" || plan.contract !== PROPAGATION_CONTRACT) {
327
+ throw new Error("infra-contract propagation apply requires a saved propagation plan");
328
+ }
329
+ if (!plan.artifactHash || typeof plan.artifactHash !== "string") {
330
+ throw new Error("infra-contract propagation plan is missing artifactHash");
331
+ }
332
+ return plan;
333
+ }
334
+
335
+ function assertInfraContractArtifact(artifact) {
336
+ if (!artifact || typeof artifact !== "object" || artifact.contract !== ARTIFACT_CONTRACT) {
337
+ throw new Error("infra-contract evidence bundle requires a saved infra-contract artifact");
338
+ }
339
+ if (!artifact.artifactHash || typeof artifact.artifactHash !== "string") {
340
+ throw new Error("infra-contract artifact is missing artifactHash");
341
+ }
342
+ const { artifactHash, ...artifactBase } = artifact;
343
+ const computedHash = sha256Buffer(stableJson(artifactBase));
344
+ if (computedHash !== artifactHash) {
345
+ throw new Error("infra-contract artifactHash does not match artifact contents");
346
+ }
347
+ return artifact;
348
+ }
349
+
350
+ function assertInfraContractApplyResult({ artifact, applyResult }) {
351
+ if (!artifact.apply?.enabled) {
352
+ if (applyResult) {
353
+ throw new Error("infra-contract apply result was provided but apply is disabled in the artifact");
354
+ }
355
+ return null;
356
+ }
357
+ if (!applyResult || typeof applyResult !== "object" || applyResult.contract !== APPLY_CONTRACT) {
358
+ throw new Error("infra-contract evidence bundle requires a saved apply result when apply is enabled");
359
+ }
360
+ if (applyResult.sourceSha && applyResult.sourceSha !== artifact.sourceSha) {
361
+ throw new Error("infra-contract apply result sourceSha does not match the artifact");
362
+ }
363
+ if (applyResult.planHash !== artifact.plan?.hash) {
364
+ throw new Error("infra-contract apply result planHash does not match the artifact plan");
365
+ }
366
+ return applyResult;
367
+ }
368
+
369
+ function assertInfraContractPropagationResult({ artifact, propagationResult }) {
370
+ if ((artifact.consumers || []).length === 0) {
371
+ if (propagationResult) {
372
+ throw new Error("infra-contract propagation result was provided but the artifact has no consumers");
373
+ }
374
+ return null;
375
+ }
376
+ if (!propagationResult || typeof propagationResult !== "object" || propagationResult.contract !== PROPAGATION_APPLY_CONTRACT) {
377
+ throw new Error("infra-contract evidence bundle requires a saved propagation apply result when consumers are configured");
378
+ }
379
+ if (propagationResult.artifactHash !== artifact.artifactHash) {
380
+ throw new Error("infra-contract propagation result artifactHash does not match the artifact");
381
+ }
382
+ return propagationResult;
383
+ }
384
+
385
+ function assertFreshApplyPlan({ cwd, plan, sourceSha, now, planMaxAgeMinutes }) {
386
+ const selectedPlan = assertInfraContractPlan(plan);
387
+ const expectedSourceSha = assertSha(sourceSha || selectedPlan.sourceSha, "sourceSha");
388
+ if (selectedPlan.sourceSha !== expectedSourceSha) {
389
+ throw new Error(`infra-contract apply sourceSha mismatch: plan has ${selectedPlan.sourceSha}, expected ${expectedSourceSha}`);
390
+ }
391
+ const plannedAtMs = Date.parse(selectedPlan.plannedAt || "");
392
+ if (!Number.isFinite(plannedAtMs)) {
393
+ throw new Error("infra-contract apply plan is missing a valid plannedAt timestamp");
394
+ }
395
+ const nowMs = Date.parse(now);
396
+ if (!Number.isFinite(nowMs)) {
397
+ throw new Error("infra-contract apply requires a valid current timestamp");
398
+ }
399
+ const maxAgeMs = Number(planMaxAgeMinutes) * 60 * 1000;
400
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
401
+ throw new Error("infra-contract apply plan max age must be a positive number of minutes");
402
+ }
403
+ const ageMs = nowMs - plannedAtMs;
404
+ if (ageMs < -5 * 60 * 1000) {
405
+ throw new Error("infra-contract apply plan timestamp is in the future");
406
+ }
407
+ if (ageMs > maxAgeMs) {
408
+ throw new Error(`infra-contract apply plan is stale: plannedAt ${selectedPlan.plannedAt} exceeds ${planMaxAgeMinutes} minute limit`);
409
+ }
410
+ const currentPlan = createInfraContractPlan({
411
+ cwd,
412
+ sourceSha: expectedSourceSha,
413
+ plannedAt: selectedPlan.plannedAt,
414
+ });
415
+ const selectedInputHash = selectedPlan.inputHash || selectedPlan.planHash;
416
+ const currentInputHash = selectedPlan.inputHash ? currentPlan.inputHash : currentPlan.planHash;
417
+ if (currentInputHash !== selectedInputHash) {
418
+ throw new Error("infra-contract apply plan no longer matches current desired, contract, or consumer inputs");
419
+ }
420
+ return {
421
+ plan: selectedPlan,
422
+ sourceSha: expectedSourceSha,
423
+ inputHash: currentInputHash,
424
+ ageSeconds: Math.max(0, Math.round(ageMs / 1000)),
425
+ planMaxAgeMinutes: Number(planMaxAgeMinutes),
426
+ };
427
+ }
428
+
429
+ export function validateInfraContractProject(cwd = process.cwd()) {
430
+ const summary = validateBuildchainConfig(cwd, {
431
+ requireConfig: true,
432
+ });
433
+ if (summary.project?.type !== "infra-contract") {
434
+ throw new Error('buildchain.toml project.type must be "infra-contract"');
435
+ }
436
+ return summary;
437
+ }
438
+
439
+ export function createInfraContractPlan({
440
+ cwd = process.cwd(),
441
+ sourceSha = "",
442
+ plannedAt = new Date().toISOString(),
443
+ executeAdapterCommands = false,
444
+ commandRunner = runShellCommand,
445
+ } = {}) {
446
+ const loadedConfig = loadBuildchainConfig(cwd);
447
+ const config = assertInfraContractConfig(loadedConfig);
448
+ const desiredFiles = configuredFileRecords(cwd, config.infra.desired);
449
+ const contractFiles = configuredFileRecords(cwd, config.infra.contract);
450
+ const capabilities = adapterCapabilities(config);
451
+ const consumerContracts = config.consumers.map((consumer) => ({
452
+ ...consumer,
453
+ sourceFile: readFileRecord(cwd, consumer.source),
454
+ }));
455
+ const selectedSourceSha = assertSha(sourceSha, "sourceSha");
456
+ const inputHash = createPlanInputFingerprint({
457
+ config,
458
+ sourceSha: selectedSourceSha,
459
+ desiredFiles,
460
+ contractFiles,
461
+ consumerContracts,
462
+ capabilities,
463
+ });
464
+ const adapterEvidence = collectAdapterEvidence({
465
+ cwd,
466
+ config,
467
+ stages: ["validate", "plan"],
468
+ executeAdapterCommands,
469
+ runner: commandRunner,
470
+ });
471
+ assertAdapterEvidencePassed(adapterEvidence);
472
+ const base = {
473
+ schemaVersion: 1,
474
+ contract: PLAN_CONTRACT,
475
+ project: {
476
+ name: config.project.name || "",
477
+ type: config.project.type,
478
+ },
479
+ sourceSha: selectedSourceSha,
480
+ plannedAt,
481
+ inputHash,
482
+ adapter: config.infra.adapter,
483
+ adoptionMode: config.infra.adoptionMode,
484
+ applyMode: config.infra.applyMode,
485
+ environment: config.infra.environment,
486
+ identityRef: config.infra.identityRef,
487
+ mutationAllowed: false,
488
+ desiredFiles: desiredFiles.map(({ json, ...entry }) => entry),
489
+ contractFiles: contractFiles.map(({ json, ...entry }) => entry),
490
+ consumers: consumerContracts.map((consumer) => ({
491
+ repo: consumer.repo,
492
+ path: consumer.path,
493
+ source: consumer.source,
494
+ branch: consumer.branch,
495
+ sourceSha256: consumer.sourceFile.sha256,
496
+ })),
497
+ adapterCapabilities: capabilities,
498
+ adapterEvidence,
499
+ requiredApprovals: config.infra.applyMode === "disabled" ? [] : [config.infra.applyMode],
500
+ issues: [],
501
+ };
502
+ const planHash = sha256Buffer(stableJson(base));
503
+ return {
504
+ ...base,
505
+ planHash,
506
+ stages: stageStatuses({ config, capabilities, planHash }),
507
+ };
508
+ }
509
+
510
+ export function createInfraContractArtifact({
511
+ cwd = process.cwd(),
512
+ sourceSha = "",
513
+ plan,
514
+ approvedBy = "",
515
+ approvalId = "",
516
+ applyRunId = "",
517
+ observedAt = new Date().toISOString(),
518
+ rollbackPointer = "",
519
+ executeAdapterCommands = false,
520
+ commandRunner = runShellCommand,
521
+ } = {}) {
522
+ const loadedConfig = loadBuildchainConfig(cwd);
523
+ const config = assertInfraContractConfig(loadedConfig);
524
+ const selectedPlan = plan || createInfraContractPlan({ cwd, sourceSha, plannedAt: observedAt });
525
+ if (selectedPlan.contract !== PLAN_CONTRACT) {
526
+ throw new Error("infra-contract artifact requires an infra-contract plan");
527
+ }
528
+ const adapterEvidence = collectAdapterEvidence({
529
+ cwd,
530
+ config,
531
+ stages: ["observe"],
532
+ executeAdapterCommands,
533
+ runner: commandRunner,
534
+ });
535
+ assertAdapterEvidencePassed(adapterEvidence);
536
+ const normalizedContractFiles = configuredFileRecords(cwd, config.infra.contract);
537
+ const artifactBase = {
538
+ schemaVersion: 1,
539
+ contract: ARTIFACT_CONTRACT,
540
+ project: selectedPlan.project,
541
+ sourceSha: selectedPlan.sourceSha,
542
+ adapter: config.infra.adapter,
543
+ adoptionMode: config.infra.adoptionMode,
544
+ applyMode: config.infra.applyMode,
545
+ environment: config.infra.environment,
546
+ identityRef: config.infra.identityRef,
547
+ desiredFiles: selectedPlan.desiredFiles,
548
+ plan: {
549
+ hash: selectedPlan.planHash,
550
+ contract: selectedPlan.contract,
551
+ },
552
+ approval: {
553
+ required: config.infra.applyMode !== "disabled",
554
+ id: approvalId,
555
+ actor: approvedBy,
556
+ },
557
+ apply: {
558
+ enabled: config.infra.applyMode !== "disabled",
559
+ runId: applyRunId,
560
+ identityRef: config.infra.identityRef,
561
+ },
562
+ observed: {
563
+ observedAt,
564
+ source: config.infra.adapter === "manual-observed" ? "reviewed-contract-files" : "adapter-observe",
565
+ files: normalizedContractFiles.map(({ path: filePath, size, sha256, json }) => ({
566
+ path: filePath,
567
+ size,
568
+ sha256,
569
+ outputs: json?.outputs || json || {},
570
+ })),
571
+ adapterEvidence,
572
+ },
573
+ consumers: selectedPlan.consumers,
574
+ rollbackPointer,
575
+ validation: {
576
+ mutationFree: true,
577
+ desiredAndObservedSeparated: true,
578
+ secretRefs: config.infra.secretRefs,
579
+ },
580
+ };
581
+ return {
582
+ ...artifactBase,
583
+ artifactHash: sha256Buffer(stableJson(artifactBase)),
584
+ };
585
+ }
586
+
587
+ export function createInfraContractPropagationPlan({
588
+ cwd = process.cwd(),
589
+ artifact,
590
+ branchPrefix = "buildchain/infra-contract",
591
+ } = {}) {
592
+ const selectedArtifact = artifact || createInfraContractArtifact({ cwd, sourceSha: "0".repeat(40) });
593
+ if (selectedArtifact.contract !== ARTIFACT_CONTRACT) {
594
+ throw new Error("infra-contract propagation requires an infra-contract artifact");
595
+ }
596
+ return {
597
+ schemaVersion: 1,
598
+ contract: PROPAGATION_CONTRACT,
599
+ sourceSha: selectedArtifact.sourceSha,
600
+ artifactHash: selectedArtifact.artifactHash,
601
+ mutationAllowed: false,
602
+ pullRequests: selectedArtifact.consumers.map((consumer) => ({
603
+ repo: consumer.repo,
604
+ branch: `${branchPrefix}/${selectedArtifact.artifactHash.slice(0, 12)}`,
605
+ path: consumer.path,
606
+ source: consumer.source,
607
+ sourceSha256: consumer.sourceSha256,
608
+ baseBranch: consumer.branch || "main",
609
+ title: "chore(infra): update Buildchain infra contract",
610
+ body: `Update ${consumer.path} from Buildchain infra contract ${selectedArtifact.artifactHash}.`,
611
+ })),
612
+ };
613
+ }
614
+
615
+ export function createInfraContractEvidenceBundle({
616
+ artifact,
617
+ applyResult,
618
+ propagationResult,
619
+ createdAt = new Date().toISOString(),
620
+ } = {}) {
621
+ const selectedArtifact = assertInfraContractArtifact(artifact);
622
+ const selectedApplyResult = assertInfraContractApplyResult({
623
+ artifact: selectedArtifact,
624
+ applyResult,
625
+ });
626
+ const selectedPropagationResult = assertInfraContractPropagationResult({
627
+ artifact: selectedArtifact,
628
+ propagationResult,
629
+ });
630
+ const bundleBase = {
631
+ schemaVersion: 1,
632
+ contract: EVIDENCE_BUNDLE_CONTRACT,
633
+ createdAt,
634
+ sourceSha: selectedArtifact.sourceSha,
635
+ artifactHash: selectedArtifact.artifactHash,
636
+ lifecycle: {
637
+ desired: {
638
+ files: selectedArtifact.desiredFiles,
639
+ },
640
+ plan: selectedArtifact.plan,
641
+ approval: selectedArtifact.approval,
642
+ apply: {
643
+ required: Boolean(selectedArtifact.apply?.enabled),
644
+ runId: selectedArtifact.apply?.runId || "",
645
+ result: selectedApplyResult,
646
+ },
647
+ observe: selectedArtifact.observed,
648
+ contract: {
649
+ hash: selectedArtifact.artifactHash,
650
+ artifact: selectedArtifact,
651
+ },
652
+ propagate: {
653
+ required: (selectedArtifact.consumers || []).length > 0,
654
+ consumers: selectedArtifact.consumers || [],
655
+ result: selectedPropagationResult,
656
+ },
657
+ },
658
+ validation: {
659
+ artifactHashVerified: true,
660
+ applyResultBound: selectedArtifact.apply?.enabled ? Boolean(selectedApplyResult) : true,
661
+ propagationResultBound: (selectedArtifact.consumers || []).length > 0 ? Boolean(selectedPropagationResult) : true,
662
+ mutationExecuted: Boolean(selectedApplyResult?.mutationExecuted),
663
+ propagationExecuted: Boolean(selectedPropagationResult?.mutationExecuted),
664
+ },
665
+ };
666
+ return {
667
+ ...bundleBase,
668
+ bundleHash: sha256Buffer(stableJson(bundleBase)),
669
+ };
670
+ }
671
+
672
+ function verificationIssue(level, code, message) {
673
+ return { level, code, message };
674
+ }
675
+
676
+ function hasIssueCode(issues, code) {
677
+ return issues.some((issue) => issue.code === code);
678
+ }
679
+
680
+ function addValidationMismatch(issues, validation, key, expected) {
681
+ if (!validation || typeof validation !== "object") {
682
+ if (!hasIssueCode(issues, "validation.missing")) {
683
+ issues.push(verificationIssue("error", "validation.missing", "evidence bundle validation summary is missing"));
684
+ }
685
+ return;
686
+ }
687
+ if (validation[key] !== expected) {
688
+ issues.push(
689
+ verificationIssue(
690
+ "error",
691
+ `validation.${key}.mismatch`,
692
+ `validation.${key} must be ${expected}`,
693
+ ),
694
+ );
695
+ }
696
+ }
697
+
698
+ export function verifyInfraContractEvidenceBundle(bundle) {
699
+ const issues = [];
700
+ if (!bundle || typeof bundle !== "object") {
701
+ return {
702
+ schemaVersion: 1,
703
+ contract: EVIDENCE_BUNDLE_VERIFICATION_CONTRACT,
704
+ ok: false,
705
+ artifactHash: "",
706
+ bundleHash: "",
707
+ lifecycle: {},
708
+ issues: [verificationIssue("error", "bundle.invalid", "infra-contract evidence bundle must be an object")],
709
+ };
710
+ }
711
+ if (bundle.contract !== EVIDENCE_BUNDLE_CONTRACT) {
712
+ issues.push(verificationIssue("error", "bundle.contract", `expected ${EVIDENCE_BUNDLE_CONTRACT}`));
713
+ }
714
+ if (!bundle.bundleHash || typeof bundle.bundleHash !== "string") {
715
+ issues.push(verificationIssue("error", "bundle.hash.missing", "evidence bundle is missing bundleHash"));
716
+ } else {
717
+ const { bundleHash, ...bundleBase } = bundle;
718
+ const computedHash = sha256Buffer(stableJson(bundleBase));
719
+ if (computedHash !== bundleHash) {
720
+ issues.push(verificationIssue("error", "bundle.hash.mismatch", "evidence bundle hash does not match bundle contents"));
721
+ }
722
+ }
723
+ const lifecycle = bundle.lifecycle && typeof bundle.lifecycle === "object" ? bundle.lifecycle : {};
724
+ const validation = bundle.validation && typeof bundle.validation === "object" ? bundle.validation : null;
725
+ for (const stage of ["desired", "plan", "approval", "apply", "observe", "contract", "propagate"]) {
726
+ if (!lifecycle[stage]) {
727
+ issues.push(verificationIssue("error", `lifecycle.${stage}.missing`, `lifecycle.${stage} evidence is missing`));
728
+ }
729
+ }
730
+ const artifact = lifecycle.contract?.artifact;
731
+ let artifactHashVerified = false;
732
+ if (!artifact || typeof artifact !== "object") {
733
+ issues.push(verificationIssue("error", "artifact.missing", "lifecycle.contract.artifact is missing"));
734
+ } else {
735
+ try {
736
+ assertInfraContractArtifact(artifact);
737
+ artifactHashVerified = true;
738
+ } catch (error) {
739
+ issues.push(verificationIssue("error", "artifact.invalid", error.message));
740
+ }
741
+ if (bundle.artifactHash && artifact.artifactHash && bundle.artifactHash !== artifact.artifactHash) {
742
+ issues.push(verificationIssue("error", "artifact.hash.mismatch", "bundle artifactHash does not match lifecycle contract artifact"));
743
+ }
744
+ if (lifecycle.contract?.hash && artifact.artifactHash && lifecycle.contract.hash !== artifact.artifactHash) {
745
+ issues.push(verificationIssue("error", "contract.hash.mismatch", "lifecycle.contract.hash does not match artifactHash"));
746
+ }
747
+ try {
748
+ assertInfraContractApplyResult({ artifact, applyResult: lifecycle.apply?.result });
749
+ } catch (error) {
750
+ issues.push(verificationIssue("error", "apply.binding.invalid", error.message));
751
+ }
752
+ try {
753
+ assertInfraContractPropagationResult({ artifact, propagationResult: lifecycle.propagate?.result });
754
+ } catch (error) {
755
+ issues.push(verificationIssue("error", "propagation.binding.invalid", error.message));
756
+ }
757
+ if (Array.isArray(artifact.desiredFiles) && Array.isArray(lifecycle.desired?.files)) {
758
+ const artifactDesired = stableJson(artifact.desiredFiles);
759
+ const lifecycleDesired = stableJson(lifecycle.desired.files);
760
+ if (artifactDesired !== lifecycleDesired) {
761
+ issues.push(verificationIssue("error", "desired.binding.mismatch", "lifecycle desired files do not match contract artifact"));
762
+ }
763
+ }
764
+ if (artifact.plan && lifecycle.plan && stableJson(artifact.plan) !== stableJson(lifecycle.plan)) {
765
+ issues.push(verificationIssue("error", "plan.binding.mismatch", "lifecycle plan evidence does not match contract artifact"));
766
+ }
767
+ if (artifact.approval && lifecycle.approval && stableJson(artifact.approval) !== stableJson(lifecycle.approval)) {
768
+ issues.push(verificationIssue("error", "approval.binding.mismatch", "lifecycle approval evidence does not match contract artifact"));
769
+ }
770
+ if (artifact.observed && lifecycle.observe && stableJson(artifact.observed) !== stableJson(lifecycle.observe)) {
771
+ issues.push(verificationIssue("error", "observe.binding.mismatch", "lifecycle observe evidence does not match contract artifact"));
772
+ }
773
+ if (Array.isArray(artifact.consumers) && Array.isArray(lifecycle.propagate?.consumers)) {
774
+ const artifactConsumers = stableJson(artifact.consumers);
775
+ const lifecycleConsumers = stableJson(lifecycle.propagate.consumers);
776
+ if (artifactConsumers !== lifecycleConsumers) {
777
+ issues.push(verificationIssue("error", "propagate.consumers.mismatch", "lifecycle propagate consumers do not match contract artifact"));
778
+ }
779
+ }
780
+ }
781
+ if (artifact && typeof artifact === "object") {
782
+ const applyResultBound = !artifact.apply?.enabled
783
+ ? lifecycle.apply?.result === null || lifecycle.apply?.result === undefined
784
+ : Boolean(lifecycle.apply?.result) && !hasIssueCode(issues, "apply.binding.invalid");
785
+ const propagationRequired = (artifact.consumers || []).length > 0;
786
+ const propagationResultBound = !propagationRequired
787
+ ? lifecycle.propagate?.result === null || lifecycle.propagate?.result === undefined
788
+ : Boolean(lifecycle.propagate?.result) && !hasIssueCode(issues, "propagation.binding.invalid");
789
+ addValidationMismatch(issues, validation, "artifactHashVerified", artifactHashVerified);
790
+ addValidationMismatch(issues, validation, "applyResultBound", applyResultBound);
791
+ addValidationMismatch(issues, validation, "propagationResultBound", propagationResultBound);
792
+ addValidationMismatch(issues, validation, "mutationExecuted", Boolean(lifecycle.apply?.result?.mutationExecuted));
793
+ addValidationMismatch(issues, validation, "propagationExecuted", Boolean(lifecycle.propagate?.result?.mutationExecuted));
794
+ }
795
+ const errorCount = issues.filter((issue) => issue.level === "error").length;
796
+ return {
797
+ schemaVersion: 1,
798
+ contract: EVIDENCE_BUNDLE_VERIFICATION_CONTRACT,
799
+ ok: errorCount === 0,
800
+ artifactHash: bundle.artifactHash || "",
801
+ bundleHash: bundle.bundleHash || "",
802
+ lifecycle: {
803
+ desired: Boolean(lifecycle.desired),
804
+ plan: Boolean(lifecycle.plan),
805
+ approval: Boolean(lifecycle.approval),
806
+ apply: Boolean(lifecycle.apply),
807
+ observe: Boolean(lifecycle.observe),
808
+ contract: Boolean(lifecycle.contract),
809
+ propagate: Boolean(lifecycle.propagate),
810
+ },
811
+ summary: {
812
+ errorCount,
813
+ issueCount: issues.length,
814
+ applyResultBound: Boolean(bundle.validation?.applyResultBound),
815
+ propagationResultBound: Boolean(bundle.validation?.propagationResultBound),
816
+ mutationExecuted: Boolean(bundle.validation?.mutationExecuted),
817
+ propagationExecuted: Boolean(bundle.validation?.propagationExecuted),
818
+ },
819
+ issues,
820
+ };
821
+ }
822
+
823
+ export function applyInfraContractPropagation({
824
+ cwd = process.cwd(),
825
+ artifact,
826
+ propagationPlan,
827
+ dryRun = true,
828
+ approvalId = "",
829
+ consumerWorkspaces = {},
830
+ runner = runCommand,
831
+ } = {}) {
832
+ const selectedPlan = assertInfraContractPropagationPlan(
833
+ propagationPlan || createInfraContractPropagationPlan({ cwd, artifact }),
834
+ );
835
+ if (artifact && artifact.contract !== ARTIFACT_CONTRACT) {
836
+ throw new Error("infra-contract propagation apply requires an infra-contract artifact");
837
+ }
838
+ if (artifact && artifact.artifactHash !== selectedPlan.artifactHash) {
839
+ throw new Error("infra-contract propagation plan artifactHash does not match the artifact");
840
+ }
841
+ if (!dryRun && !approvalId) {
842
+ throw new Error("infra-contract propagation apply requires an approval id before opening consumer PRs");
843
+ }
844
+
845
+ const operations = selectedPlan.pullRequests.map((request) => {
846
+ const sourceRecord = readFileRecord(cwd, request.source);
847
+ if (request.sourceSha256 && request.sourceSha256 !== sourceRecord.sha256) {
848
+ throw new Error(`infra-contract propagation source drifted for ${request.source}`);
849
+ }
850
+ const workspace = consumerWorkspaces[request.repo] || "";
851
+ const targetPath = assertSafeInfraPath(request.path);
852
+ const operation = {
853
+ repo: request.repo,
854
+ branch: request.branch,
855
+ baseBranch: request.baseBranch || "main",
856
+ path: targetPath,
857
+ source: request.source,
858
+ sourceSha256: sourceRecord.sha256,
859
+ workspace,
860
+ title: request.title,
861
+ body: request.body,
862
+ status: dryRun ? "planned" : "pending",
863
+ executed: false,
864
+ pullRequestUrl: "",
865
+ commands: [
866
+ ["git", "-C", workspace || "<consumer-workspace>", "checkout", "-B", request.branch],
867
+ ["git", "-C", workspace || "<consumer-workspace>", "add", targetPath],
868
+ ["git", "-C", workspace || "<consumer-workspace>", "commit", "-m", request.title],
869
+ ["git", "-C", workspace || "<consumer-workspace>", "push", "-u", "origin", request.branch],
870
+ ["gh", "pr", "create", "--repo", request.repo, "--base", request.baseBranch || "main", "--head", request.branch],
871
+ ],
872
+ };
873
+
874
+ if (dryRun) {
875
+ return operation;
876
+ }
877
+
878
+ if (!workspace) {
879
+ throw new Error(`infra-contract propagation apply requires --consumer-workspace for ${request.repo}`);
880
+ }
881
+ if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
882
+ throw new Error(`infra-contract consumer workspace does not exist: ${workspace}`);
883
+ }
884
+ const status = assertCommandSuccess(runner("git", ["-C", workspace, "status", "--porcelain"]), `${request.repo} status`);
885
+ if (status.stdout.trim()) {
886
+ throw new Error(`infra-contract consumer workspace must be clean before propagation: ${request.repo}`);
887
+ }
888
+ assertCommandSuccess(runner("git", ["-C", workspace, "checkout", "-B", request.branch]), `${request.repo} branch`);
889
+ const targetFile = path.join(workspace, targetPath);
890
+ fs.mkdirSync(path.dirname(targetFile), { recursive: true });
891
+ fs.copyFileSync(path.join(cwd, assertSafeInfraPath(request.source)), targetFile);
892
+ assertCommandSuccess(runner("git", ["-C", workspace, "add", targetPath]), `${request.repo} add`);
893
+ const diff = runner("git", ["-C", workspace, "diff", "--cached", "--quiet"]);
894
+ if (diff.status === 0) {
895
+ return {
896
+ ...operation,
897
+ status: "unchanged",
898
+ executed: true,
899
+ };
900
+ }
901
+ if (diff.status !== 1) {
902
+ throw new Error(`${request.repo} diff failed: ${diff.stderr || diff.stdout || `exit ${diff.status}`}`);
903
+ }
904
+ assertCommandSuccess(runner("git", ["-C", workspace, "commit", "-m", request.title, "-m", request.body]), `${request.repo} commit`);
905
+ assertCommandSuccess(runner("git", ["-C", workspace, "push", "-u", "origin", request.branch]), `${request.repo} push`);
906
+ const pr = assertCommandSuccess(
907
+ runner("gh", [
908
+ "pr",
909
+ "create",
910
+ "--repo",
911
+ request.repo,
912
+ "--base",
913
+ request.baseBranch || "main",
914
+ "--head",
915
+ request.branch,
916
+ "--title",
917
+ request.title,
918
+ "--body",
919
+ request.body,
920
+ ]),
921
+ `${request.repo} pr create`,
922
+ );
923
+ return {
924
+ ...operation,
925
+ status: "opened",
926
+ executed: true,
927
+ pullRequestUrl: pr.stdout.trim(),
928
+ };
929
+ });
930
+
931
+ return {
932
+ schemaVersion: 1,
933
+ contract: PROPAGATION_APPLY_CONTRACT,
934
+ artifactHash: selectedPlan.artifactHash,
935
+ dryRun,
936
+ approvalId,
937
+ mutationAllowed: !dryRun,
938
+ mutationExecuted: !dryRun && operations.some((operation) => operation.executed),
939
+ status: dryRun ? "planned" : "completed",
940
+ operations,
941
+ };
942
+ }
943
+
944
+ export function applyInfraContract({
945
+ cwd = process.cwd(),
946
+ sourceSha = "",
947
+ approvalId = "",
948
+ dryRun = true,
949
+ plan,
950
+ now = new Date().toISOString(),
951
+ planMaxAgeMinutes = 60,
952
+ executeAdapterCommands = false,
953
+ commandRunner = runShellCommand,
954
+ } = {}) {
955
+ const loadedConfig = loadBuildchainConfig(cwd);
956
+ const config = assertInfraContractConfig(loadedConfig);
957
+ const capabilities = adapterCapabilities(config);
958
+ if (config.infra.applyMode === "disabled") {
959
+ throw new Error("infra-contract apply is disabled by config");
960
+ }
961
+ if (!capabilities.apply) {
962
+ throw new Error(`infra adapter ${config.infra.adapter} does not support apply`);
963
+ }
964
+ if (!approvalId) {
965
+ throw new Error("infra-contract apply requires an approval id before mutation");
966
+ }
967
+ if (!config.infra.environment) {
968
+ throw new Error("infra-contract apply requires infra.environment before mutation");
969
+ }
970
+ if (!config.infra.identityRef) {
971
+ throw new Error("infra-contract apply requires infra.identity_ref before mutation");
972
+ }
973
+ const freshPlan = assertFreshApplyPlan({ cwd, plan, sourceSha, now, planMaxAgeMinutes });
974
+ const plannedApplyEvidence = collectAdapterEvidence({
975
+ cwd,
976
+ config,
977
+ stages: ["apply"],
978
+ executeAdapterCommands: false,
979
+ runner: commandRunner,
980
+ });
981
+ if (dryRun) {
982
+ return {
983
+ schemaVersion: 1,
984
+ contract: APPLY_CONTRACT,
985
+ status: "planned",
986
+ dryRun: true,
987
+ approvalId,
988
+ sourceSha: freshPlan.sourceSha,
989
+ planHash: freshPlan.plan.planHash,
990
+ inputHash: freshPlan.inputHash,
991
+ environment: config.infra.environment,
992
+ identityRef: config.infra.identityRef,
993
+ planAgeSeconds: freshPlan.ageSeconds,
994
+ planMaxAgeMinutes: freshPlan.planMaxAgeMinutes,
995
+ mutationAllowed: false,
996
+ mutationExecuted: false,
997
+ adapterEvidence: plannedApplyEvidence,
998
+ };
999
+ }
1000
+ if (!executeAdapterCommands) {
1001
+ throw new Error("infra-contract apply requires --execute-adapter-commands true before mutation");
1002
+ }
1003
+ const applyTemplate = adapterCommandTemplate({ config, stage: "apply" });
1004
+ if (!applyTemplate?.executable) {
1005
+ throw new Error(`infra-contract apply execution requires infra.commands.apply for adapter: ${config.infra.adapter}`);
1006
+ }
1007
+ const adapterEvidence = collectAdapterEvidence({
1008
+ cwd,
1009
+ config,
1010
+ stages: ["apply"],
1011
+ executeAdapterCommands: true,
1012
+ runner: commandRunner,
1013
+ });
1014
+ assertAdapterEvidencePassed(adapterEvidence);
1015
+ return {
1016
+ schemaVersion: 1,
1017
+ contract: APPLY_CONTRACT,
1018
+ status: "completed",
1019
+ dryRun: false,
1020
+ approvalId,
1021
+ sourceSha: freshPlan.sourceSha,
1022
+ planHash: freshPlan.plan.planHash,
1023
+ inputHash: freshPlan.inputHash,
1024
+ environment: config.infra.environment,
1025
+ identityRef: config.infra.identityRef,
1026
+ planAgeSeconds: freshPlan.ageSeconds,
1027
+ planMaxAgeMinutes: freshPlan.planMaxAgeMinutes,
1028
+ mutationAllowed: true,
1029
+ mutationExecuted: true,
1030
+ adapterEvidence,
1031
+ };
1032
+ }