@intentius/chant 0.53.1 → 0.54.1
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/dist/audit/catalog.d.ts +4 -0
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +9 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/nginx.d.ts +53 -0
- package/dist/audit/nginx.d.ts.map +1 -0
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-status.d.ts +56 -0
- package/dist/cli/commands/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/carve-status.d.ts +9 -0
- package/dist/cli/handlers/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +3 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +7 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +8 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +55 -1
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +15 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +31 -6
- package/dist/terraform/manifest.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.ts +35 -0
- package/src/audit/discover.ts +11 -0
- package/src/audit/edge-init-safety.test.ts +36 -3
- package/src/audit/nginx.test.ts +180 -0
- package/src/audit/nginx.ts +379 -0
- package/src/audit/rules-doc.ts +1 -0
- package/src/cli/commands/audit.test.ts +35 -0
- package/src/cli/commands/audit.ts +8 -3
- package/src/cli/commands/carve-apply.test.ts +4 -1
- package/src/cli/commands/carve-apply.ts +4 -2
- package/src/cli/commands/carve-bridge.test.ts +2 -1
- package/src/cli/commands/carve-emit-state.test.ts +3 -1
- package/src/cli/commands/carve-status.test.ts +116 -0
- package/src/cli/commands/carve-status.ts +163 -0
- package/src/cli/handlers/carve-status.ts +30 -0
- package/src/cli/handlers/components.ts +5 -1
- package/src/cli/handlers/graph.ts +3 -0
- package/src/cli/handlers/run.ts +4 -1
- package/src/cli/main.ts +8 -0
- package/src/components/auto-release.ts +4 -1
- package/src/components/cli-support.ts +4 -2
- package/src/graph-ir.ts +7 -0
- package/src/lexicon.ts +8 -0
- package/src/lifecycle/release-ledger.test.ts +64 -0
- package/src/lifecycle/release-ledger.ts +79 -1
- package/src/op/activity-contract.ts +14 -0
- package/src/terraform/manifest.test.ts +52 -0
- package/src/terraform/manifest.ts +72 -10
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve status` — the read over a tree of carve manifests (#2038).
|
|
3
|
+
*
|
|
4
|
+
* emit → bridge → apply each persist their step into `<target>.carve.json`
|
|
5
|
+
* (./../../terraform/manifest.ts), but until now nothing read that state back
|
|
6
|
+
* over more than one output dir at a time: a renderer wanting "what carves
|
|
7
|
+
* exist under this project, and how far along is each" had to walk the tree
|
|
8
|
+
* itself and guess at depth. This makes the walk a contract: recurse from
|
|
9
|
+
* `--from` (default: cwd), read every manifest the way bridge/apply's own
|
|
10
|
+
* `resolveCarveManifest` reads one, and answer with each manifest's target,
|
|
11
|
+
* stage, and path. Read-only — it writes nothing and touches no live resource.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
15
|
+
import { join, relative, resolve } from "path";
|
|
16
|
+
import {
|
|
17
|
+
CARVE_MANIFEST_SUFFIX,
|
|
18
|
+
readCarveManifest,
|
|
19
|
+
type CarveManifest,
|
|
20
|
+
} from "../../terraform/manifest";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How far along a carve is — the highest recorded step. `planned` is a
|
|
24
|
+
* manifest holding only the boundary classification (written, but `carve
|
|
25
|
+
* emit` recorded no emit section — e.g. a hand-seeded or partially-run
|
|
26
|
+
* carve).
|
|
27
|
+
*/
|
|
28
|
+
export type CarveStage = "planned" | "emitted" | "bridged" | "applied";
|
|
29
|
+
|
|
30
|
+
export interface CarveStatusRow {
|
|
31
|
+
/** Manifest path, relative to the walk root. */
|
|
32
|
+
path: string;
|
|
33
|
+
/** Terraform address of the carved resource. */
|
|
34
|
+
target: string;
|
|
35
|
+
/** Terraform resource type, when the manifest recorded one. */
|
|
36
|
+
tfType?: string;
|
|
37
|
+
/** The highest recorded step. */
|
|
38
|
+
stage: CarveStage;
|
|
39
|
+
/** ISO-8601 timestamp of each recorded step. */
|
|
40
|
+
at: { emit?: string; bridge?: string; apply?: string };
|
|
41
|
+
/** Emitted chant source file path(s), exactly as the manifest records them. */
|
|
42
|
+
emittedFiles?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CarveStatusOptions {
|
|
46
|
+
/** Root of the walk (`--from`). Default: the current directory. */
|
|
47
|
+
from?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CarveStatusResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
error?: string;
|
|
53
|
+
/** The resolved walk root. */
|
|
54
|
+
from?: string;
|
|
55
|
+
/** One row per readable manifest, sorted by path. */
|
|
56
|
+
carves?: CarveStatusRow[];
|
|
57
|
+
/** Manifest-suffixed files that did not read as a manifest (wrong version, malformed). */
|
|
58
|
+
unreadable?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Directories that never hold a carve output dir and make the walk expensive
|
|
63
|
+
* or wrong: dependency trees, VCS internals, build output.
|
|
64
|
+
*/
|
|
65
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".chant"]);
|
|
66
|
+
|
|
67
|
+
/** Every `*.carve.json` under `root`, found by a bounded recursive walk. */
|
|
68
|
+
function findManifests(root: string): string[] {
|
|
69
|
+
const found: string[] = [];
|
|
70
|
+
const walk = (dir: string): void => {
|
|
71
|
+
let entries: string[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dir);
|
|
74
|
+
} catch {
|
|
75
|
+
return; // unreadable dir — skip rather than fail the whole status
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries.sort()) {
|
|
78
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
79
|
+
const path = join(dir, entry);
|
|
80
|
+
let stat;
|
|
81
|
+
try {
|
|
82
|
+
stat = statSync(path);
|
|
83
|
+
} catch {
|
|
84
|
+
continue; // dangling symlink
|
|
85
|
+
}
|
|
86
|
+
if (stat.isDirectory()) walk(path);
|
|
87
|
+
else if (entry.endsWith(CARVE_MANIFEST_SUFFIX)) found.push(path);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(root);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stageOf(manifest: CarveManifest): CarveStage {
|
|
95
|
+
if (manifest.apply) return "applied";
|
|
96
|
+
if (manifest.bridge) return "bridged";
|
|
97
|
+
if (manifest.emit) return "emitted";
|
|
98
|
+
return "planned";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function carveStatus(opts: CarveStatusOptions = {}): CarveStatusResult {
|
|
102
|
+
const root = resolve(opts.from ?? process.cwd());
|
|
103
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
104
|
+
return { ok: false, error: `Not a directory: ${root}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const carves: CarveStatusRow[] = [];
|
|
108
|
+
const unreadable: string[] = [];
|
|
109
|
+
for (const path of findManifests(root)) {
|
|
110
|
+
const rel = relative(root, path);
|
|
111
|
+
const manifest = readCarveManifest(path);
|
|
112
|
+
if (!manifest) {
|
|
113
|
+
unreadable.push(rel);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
carves.push({
|
|
117
|
+
path: rel,
|
|
118
|
+
target: manifest.target,
|
|
119
|
+
...(manifest.tfType ? { tfType: manifest.tfType } : {}),
|
|
120
|
+
stage: stageOf(manifest),
|
|
121
|
+
at: {
|
|
122
|
+
...(manifest.emit?.at ? { emit: manifest.emit.at } : {}),
|
|
123
|
+
...(manifest.bridge?.at ? { bridge: manifest.bridge.at } : {}),
|
|
124
|
+
...(manifest.apply?.at ? { apply: manifest.apply.at } : {}),
|
|
125
|
+
},
|
|
126
|
+
...(manifest.emit ? { emittedFiles: manifest.emit.files } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ok: true, from: root, carves, ...(unreadable.length > 0 ? { unreadable } : {}) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The JSON payload `--json` emits — everything but `ok`. */
|
|
134
|
+
export function carveStatusJson(result: CarveStatusResult): Record<string, unknown> {
|
|
135
|
+
return {
|
|
136
|
+
from: result.from,
|
|
137
|
+
carves: result.carves ?? [],
|
|
138
|
+
...(result.unreadable ? { unreadable: result.unreadable } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatCarveStatus(result: CarveStatusResult): string {
|
|
143
|
+
const carves = result.carves ?? [];
|
|
144
|
+
if (carves.length === 0) {
|
|
145
|
+
return `No carve manifests under ${result.from} — run \`chant carve emit\` first.`;
|
|
146
|
+
}
|
|
147
|
+
const L: string[] = [];
|
|
148
|
+
L.push(`${carves.length} carve${carves.length === 1 ? "" : "s"} under ${result.from}`);
|
|
149
|
+
L.push("");
|
|
150
|
+
for (const row of carves) {
|
|
151
|
+
const when = row.at.apply ?? row.at.bridge ?? row.at.emit;
|
|
152
|
+
L.push(` ${row.target} [${row.stage}${when ? ` @ ${when}` : ""}]`);
|
|
153
|
+
L.push(` manifest: ${row.path}`);
|
|
154
|
+
if (row.emittedFiles && row.emittedFiles.length > 0) {
|
|
155
|
+
L.push(` emitted: ${row.emittedFiles.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (result.unreadable && result.unreadable.length > 0) {
|
|
159
|
+
L.push("");
|
|
160
|
+
L.push(` Unreadable (malformed or unknown version): ${result.unreadable.join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
return L.join("\n");
|
|
163
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { carveStatus, carveStatusJson, formatCarveStatus } from "../commands/carve-status";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import type { CommandContext } from "../registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `chant carve status [--from <dir>] [--json]` — the read-only status read
|
|
7
|
+
* over a tree of carve manifests (#2038). Walks from `--from` (default: cwd),
|
|
8
|
+
* reports every manifest's target, stage (planned/emitted/bridged/applied)
|
|
9
|
+
* and path. No plugins, no project, no cloud call.
|
|
10
|
+
*/
|
|
11
|
+
export async function runCarveStatus(ctx: CommandContext): Promise<number> {
|
|
12
|
+
const { args } = ctx;
|
|
13
|
+
|
|
14
|
+
const result = carveStatus({ from: args.migrateFrom /* `--from <dir>` */ });
|
|
15
|
+
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
console.error(formatError({
|
|
18
|
+
message: result.error ?? "carve status failed",
|
|
19
|
+
hint: "chant carve status --from ./tf --json",
|
|
20
|
+
}));
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (args.json) {
|
|
25
|
+
console.log(JSON.stringify(carveStatusJson(result), null, 2));
|
|
26
|
+
} else {
|
|
27
|
+
console.log(formatCarveStatus(result));
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readReleaseLedger,
|
|
35
35
|
listReleaseEnvironments,
|
|
36
36
|
latestPerComponent,
|
|
37
|
+
resolveRunId,
|
|
37
38
|
InvalidReleaseRecordError,
|
|
38
39
|
} from "../../lifecycle/release-ledger";
|
|
39
40
|
import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
|
|
@@ -94,7 +95,9 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
const gitSha = args.gitSha ?? (await getHeadCommit().catch(() => undefined));
|
|
97
|
-
|
|
98
|
+
// Id + origin resolved together from the same environment (#2045), so the
|
|
99
|
+
// recorded runId always says which id space it lives in and how to reach it.
|
|
100
|
+
const { runId, runOrigin } = resolveRunId(args.runId);
|
|
98
101
|
const actor = args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
|
|
99
102
|
// Who approved a gated change (#1035). Optional — omitted for an ungated
|
|
100
103
|
// change, and never defaulted to `actor`, since recording the approver as the
|
|
@@ -122,6 +125,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
122
125
|
digest,
|
|
123
126
|
gitSha,
|
|
124
127
|
runId,
|
|
128
|
+
...(runOrigin ? { runOrigin } : {}),
|
|
125
129
|
timestamp,
|
|
126
130
|
actor,
|
|
127
131
|
...(approver ? { approver } : {}),
|
|
@@ -635,6 +635,9 @@ async function buildPipelineProjection(
|
|
|
635
635
|
success: true,
|
|
636
636
|
pipeline: {
|
|
637
637
|
provider: lexicon,
|
|
638
|
+
// The environment the projected pipeline deploys (#2046) — the
|
|
639
|
+
// generator's own resolution, carried rather than re-derived.
|
|
640
|
+
...(result.env ? { env: result.env } : {}),
|
|
638
641
|
stages: result.stages ?? [],
|
|
639
642
|
nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
|
|
640
643
|
edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -655,6 +655,8 @@ async function recordAutoReleasesForRun(
|
|
|
655
655
|
success: true,
|
|
656
656
|
records: componentResult.records,
|
|
657
657
|
runId,
|
|
658
|
+
// A local-executor id resolves nowhere — say so in a field (#2045).
|
|
659
|
+
runOrigin: { forge: "local" },
|
|
658
660
|
},
|
|
659
661
|
{ disabled },
|
|
660
662
|
);
|
|
@@ -1030,7 +1032,8 @@ async function runComponentTemporal(
|
|
|
1030
1032
|
const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord);
|
|
1031
1033
|
const env = ctx.args.env ?? "local";
|
|
1032
1034
|
const outcome = await maybeRecordAutoRelease(
|
|
1033
|
-
|
|
1035
|
+
// A Temporal workflow run id lives in the orchestrator's id space (#2045).
|
|
1036
|
+
{ component: component.name, env, success: true, digest, runId: finalDesc.runId, runOrigin: { forge: "op" } },
|
|
1034
1037
|
{ disabled },
|
|
1035
1038
|
);
|
|
1036
1039
|
if (!outcome.recorded && outcome.reason === "error") {
|
package/src/cli/main.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
|
|
|
23
23
|
import { runCarveEmit } from "./handlers/carve-emit";
|
|
24
24
|
import { runCarveBridge } from "./handlers/carve-bridge";
|
|
25
25
|
import { runCarveApply } from "./handlers/carve-apply";
|
|
26
|
+
import { runCarveStatus } from "./handlers/carve-status";
|
|
26
27
|
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
27
28
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
|
|
28
29
|
import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
|
|
@@ -498,6 +499,10 @@ Commands:
|
|
|
498
499
|
--env <env> is optional with a carve manifest present.
|
|
499
500
|
--write-source stamps the ownership marker
|
|
500
501
|
into the emitted chant source.
|
|
502
|
+
carve status Status read over a tree of carve manifests: every
|
|
503
|
+
[--from <dir>] *.carve.json under --from (default: cwd) with
|
|
504
|
+
(--json) its target, stage (planned/emitted/bridged/
|
|
505
|
+
applied) and path. Read-only.
|
|
501
506
|
|
|
502
507
|
Ops:
|
|
503
508
|
run <name> Start an Op workflow (spawns worker + submits to Temporal)
|
|
@@ -872,6 +877,9 @@ const registry: CommandDef[] = [
|
|
|
872
877
|
// Apply graduation (#197): ownership marker + finalized apply runbook.
|
|
873
878
|
// BYOL-honest — no cloud call; --write saves the graduation doc.
|
|
874
879
|
{ name: "carve apply", handler: runCarveApply },
|
|
880
|
+
// Status read over a tree of carve manifests (#2038): the contract a
|
|
881
|
+
// renderer replaces its own walk-and-guess discovery with. Read-only.
|
|
882
|
+
{ name: "carve status", handler: runCarveStatus },
|
|
875
883
|
{ name: "init", handler: runInit },
|
|
876
884
|
{ name: "init lexicon", handler: runInitLexicon },
|
|
877
885
|
{ name: "update", handler: runUpdate },
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* env vars, timestamp is a real `new Date()` taken at record time).
|
|
31
31
|
*/
|
|
32
32
|
import { getHeadCommit, pushLifecycle, StaleLifecycleBranchError } from "../lifecycle/git";
|
|
33
|
-
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord } from "../lifecycle/release-ledger";
|
|
33
|
+
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
|
|
34
34
|
import type { DriverStepRecord } from "./driver";
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -117,6 +117,8 @@ export interface AutoReleaseRunInfo {
|
|
|
117
117
|
digest?: string;
|
|
118
118
|
/** Orchestrator run identifier — a Temporal `runId`, or a locally generated id for the local executor (mirrors `runComponentsReleaseRecord`'s `--run-id` default). */
|
|
119
119
|
runId: string;
|
|
120
|
+
/** The id space `runId` lives in (#2045) — `{ forge: "op" }` for a Temporal run, `{ forge: "local" }` for a local-executor mint. Recorded verbatim as the release record's `runOrigin`. */
|
|
121
|
+
runOrigin?: RunOrigin;
|
|
120
122
|
/** The bypassed capability-profile divergences, when the caller deliberately overrode a deploy-time profile assertion (chant #1244) — recorded verbatim as the release record's `profileOverride`. */
|
|
121
123
|
profileOverride?: string;
|
|
122
124
|
/** The deploy's input-side digest, when `digest` is a rendered-content identity (a pinned helm deploy, chant #1242) — recorded as the release record's `inputDigest` so ledger queries can still join on inputs across clusters whose bytes legitimately differ. */
|
|
@@ -204,6 +206,7 @@ export async function maybeRecordAutoRelease(
|
|
|
204
206
|
digest,
|
|
205
207
|
gitSha,
|
|
206
208
|
runId: run.runId,
|
|
209
|
+
...(run.runOrigin ? { runOrigin: run.runOrigin } : {}),
|
|
207
210
|
timestamp,
|
|
208
211
|
actor,
|
|
209
212
|
...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
|
|
@@ -218,6 +218,8 @@ export interface GenerateComponentsResult {
|
|
|
218
218
|
stages?: string[];
|
|
219
219
|
/** Every generated job, for a machine-readable view (`--format json`). */
|
|
220
220
|
jobs?: Array<{ jobName: string; component: string; stage: string; needs: string[] }>;
|
|
221
|
+
/** The environment the pipeline deploys — the generator's own resolution (`ComponentPipelineResult.env`, #2046). */
|
|
222
|
+
env?: string;
|
|
221
223
|
error?: string;
|
|
222
224
|
/** This invocation's resolved build-time parameters (chant #1108) — the generate-mode counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Empty when the project declares/supplies none. */
|
|
223
225
|
buildParams?: BuildParamProvenance[];
|
|
@@ -287,8 +289,8 @@ export async function generateComponentsPipeline(
|
|
|
287
289
|
}));
|
|
288
290
|
|
|
289
291
|
try {
|
|
290
|
-
const { yaml, stages, jobs } = plugin.generateComponentPipeline(driverComponents, options);
|
|
291
|
-
return { success: true, yaml, stages, jobs, buildParams };
|
|
292
|
+
const { yaml, stages, jobs, env } = plugin.generateComponentPipeline(driverComponents, options);
|
|
293
|
+
return { success: true, yaml, stages, jobs, ...(env ? { env } : {}), buildParams };
|
|
292
294
|
} catch (err) {
|
|
293
295
|
if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
|
|
294
296
|
return { success: false, error: err.message };
|
package/src/graph-ir.ts
CHANGED
|
@@ -190,6 +190,13 @@ export interface IRPipelineEdge {
|
|
|
190
190
|
export interface IRPipeline {
|
|
191
191
|
/** The CI-provider lexicon that produced this projection (e.g. "gitlab"). */
|
|
192
192
|
provider: string;
|
|
193
|
+
/**
|
|
194
|
+
* The environment the projected pipeline deploys (#2046) — copied from the
|
|
195
|
+
* generator's own resolution (`ComponentPipelineResult.env`), so the IR
|
|
196
|
+
* carries the identity instead of leaving a consumer to lex it out of a
|
|
197
|
+
* job's run line.
|
|
198
|
+
*/
|
|
199
|
+
env?: string;
|
|
193
200
|
/** Wave-ordered stage names — 1:1 with the component graph's `groups.byWave` keys. */
|
|
194
201
|
stages: string[];
|
|
195
202
|
nodes: IRPipelineNode[];
|
package/src/lexicon.ts
CHANGED
|
@@ -461,6 +461,14 @@ export interface ComponentPipelineResult {
|
|
|
461
461
|
stages: string[];
|
|
462
462
|
/** Every generated job, in emit order. */
|
|
463
463
|
jobs: ComponentPipelineJob[];
|
|
464
|
+
/**
|
|
465
|
+
* The environment this pipeline deploys — `options.env` with the
|
|
466
|
+
* generator's default applied (#2046). Generators set it so a consumer
|
|
467
|
+
* (the graph IR's `pipeline` projection, behold) can join a pipeline to
|
|
468
|
+
* its environment without lexing a job's `run:`/`script:` line, where the
|
|
469
|
+
* value otherwise survives only as a shell argument.
|
|
470
|
+
*/
|
|
471
|
+
env?: string;
|
|
464
472
|
}
|
|
465
473
|
|
|
466
474
|
/**
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
InvalidReleaseRecordError,
|
|
12
12
|
latestPerComponent,
|
|
13
13
|
recordsForDigest,
|
|
14
|
+
resolveRunId,
|
|
14
15
|
type ReleaseRecord,
|
|
15
16
|
type ReleaseRecordInput,
|
|
16
17
|
} from "./release-ledger";
|
|
@@ -234,4 +235,67 @@ describe("release-ledger", () => {
|
|
|
234
235
|
expect(found.every((r) => r.digest === "sha256:shared")).toBe(true);
|
|
235
236
|
});
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
describe("resolveRunId (#2045)", () => {
|
|
240
|
+
test("GitHub Actions env resolves id + origin together, with repo and run url", () => {
|
|
241
|
+
const env = {
|
|
242
|
+
GITHUB_RUN_ID: "18234771902",
|
|
243
|
+
GITHUB_REPOSITORY: "INTENTIUS/chant",
|
|
244
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
245
|
+
};
|
|
246
|
+
expect(resolveRunId(undefined, env)).toEqual({
|
|
247
|
+
runId: "18234771902",
|
|
248
|
+
runOrigin: {
|
|
249
|
+
forge: "github",
|
|
250
|
+
repo: "INTENTIUS/chant",
|
|
251
|
+
url: "https://github.com/INTENTIUS/chant/actions/runs/18234771902",
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a Forgejo instance resolves through the same env contract to its own host", () => {
|
|
257
|
+
const env = {
|
|
258
|
+
GITHUB_RUN_ID: "42",
|
|
259
|
+
GITHUB_REPOSITORY: "intentius/loomster",
|
|
260
|
+
GITHUB_SERVER_URL: "https://forge.example.dev",
|
|
261
|
+
};
|
|
262
|
+
expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/intentius/loomster/actions/runs/42");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
|
|
266
|
+
const env = {
|
|
267
|
+
CI_PIPELINE_ID: "991",
|
|
268
|
+
CI_PROJECT_PATH: "group/app",
|
|
269
|
+
CI_PIPELINE_URL: "https://gitlab.com/group/app/-/pipelines/991",
|
|
270
|
+
};
|
|
271
|
+
expect(resolveRunId(undefined, env)).toEqual({
|
|
272
|
+
runId: "991",
|
|
273
|
+
runOrigin: { forge: "gitlab", repo: "group/app", url: "https://gitlab.com/group/app/-/pipelines/991" },
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("outside CI the mint says local in a field, not only in the id's spelling", () => {
|
|
278
|
+
const { runId, runOrigin } = resolveRunId(undefined, {});
|
|
279
|
+
expect(runId).toMatch(/^local-\d+$/);
|
|
280
|
+
expect(runOrigin).toEqual({ forge: "local" });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("an explicit id gets an origin only when it matches what the environment advertises", () => {
|
|
284
|
+
const env = { GITHUB_RUN_ID: "42", GITHUB_REPOSITORY: "o/r", GITHUB_SERVER_URL: "https://github.com" };
|
|
285
|
+
expect(resolveRunId("42", env).runOrigin?.forge).toBe("github");
|
|
286
|
+
expect(resolveRunId("something-else", env).runOrigin).toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a record carrying runOrigin round-trips through the ledger", async () => {
|
|
290
|
+
await withTestDir(async (dir) => {
|
|
291
|
+
await initRepo(dir);
|
|
292
|
+
const input = makeInput({
|
|
293
|
+
runOrigin: { forge: "github", repo: "o/r", url: "https://github.com/o/r/actions/runs/1" },
|
|
294
|
+
});
|
|
295
|
+
await appendReleaseRecord(input, { cwd: dir });
|
|
296
|
+
const { records } = await readReleaseLedger("prod", { cwd: dir });
|
|
297
|
+
expect(records[0].runOrigin).toEqual(input.runOrigin);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
237
301
|
});
|
|
@@ -31,6 +31,25 @@
|
|
|
31
31
|
import { sortedJsonReplacer } from "../utils";
|
|
32
32
|
import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The id space a `ReleaseRecord.runId` lives in, and how to reach the run
|
|
36
|
+
* (#2045).
|
|
37
|
+
*/
|
|
38
|
+
export interface RunOrigin {
|
|
39
|
+
/**
|
|
40
|
+
* Which id space minted `runId`: `github` (a GitHub Actions run id — also
|
|
41
|
+
* the spelling Forgejo Actions uses, since it implements the same env
|
|
42
|
+
* contract; `url` disambiguates the actual host), `gitlab` (a CI pipeline
|
|
43
|
+
* id), `op` (an orchestrator/Op run id), or `local` (minted on the machine
|
|
44
|
+
* that ran the deploy, resolvable nowhere).
|
|
45
|
+
*/
|
|
46
|
+
forge: "github" | "gitlab" | "op" | "local";
|
|
47
|
+
/** Repo/project slug on the forge (`GITHUB_REPOSITORY` / `CI_PROJECT_PATH`), when the environment named one. */
|
|
48
|
+
repo?: string;
|
|
49
|
+
/** Resolved link to the run, when the environment provided enough to build one. */
|
|
50
|
+
url?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
/**
|
|
35
54
|
* One immutable deploy record: `(component, env, artifact digest, git sha,
|
|
36
55
|
* run id, timestamp, actor)`, referencing the build archive by digest — the
|
|
@@ -54,8 +73,19 @@ export interface ReleaseRecord {
|
|
|
54
73
|
digest: string;
|
|
55
74
|
/** Git commit SHA the deploy was built from. */
|
|
56
75
|
gitSha: string;
|
|
57
|
-
/** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. */
|
|
76
|
+
/** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. Which id space it lives in, and how to follow it, is `runOrigin`'s job (#2045). */
|
|
58
77
|
runId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Where `runId` can be resolved (#2045). A bare `runId` names a run in one
|
|
80
|
+
* of several mutually incompatible id spaces — a GitHub Actions run id, a
|
|
81
|
+
* GitLab pipeline id, an Op run id, a locally minted id — and without this
|
|
82
|
+
* a reader can tell them apart only by inference from whoever wrote the
|
|
83
|
+
* record. Written at record time from the same environment the id came
|
|
84
|
+
* from, so the two cannot skew. Optional: records written before this
|
|
85
|
+
* field existed, and callers passing an id whose origin they did not
|
|
86
|
+
* declare, simply have none.
|
|
87
|
+
*/
|
|
88
|
+
runOrigin?: RunOrigin;
|
|
59
89
|
/**
|
|
60
90
|
* When this record was written. The caller supplies this — the session
|
|
61
91
|
* cannot call `Date.now()` reliably in every context, so the CLI resolves
|
|
@@ -132,6 +162,54 @@ export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[]
|
|
|
132
162
|
/** Input a caller supplies to record one deploy — `version` is filled in, everything else is required (see `ReleaseRecord`). */
|
|
133
163
|
export type ReleaseRecordInput = Omit<ReleaseRecord, "version">;
|
|
134
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a run id and its origin from the same environment, in one step, so
|
|
167
|
+
* the two cannot skew (#2045). Resolution order mirrors what
|
|
168
|
+
* `chant components release` always did — explicit id, then
|
|
169
|
+
* `GITHUB_RUN_ID`, then `CI_PIPELINE_ID`, then a locally minted
|
|
170
|
+
* `local-<ms>` — but now each source also names its id space:
|
|
171
|
+
*
|
|
172
|
+
* - the GitHub path records `repo` (`GITHUB_REPOSITORY`) and builds the run
|
|
173
|
+
* `url` from `GITHUB_SERVER_URL` — which also makes a Forgejo instance's
|
|
174
|
+
* runs resolvable, since Forgejo Actions implements the same env contract;
|
|
175
|
+
* - the GitLab path records `repo` (`CI_PROJECT_PATH`) and takes
|
|
176
|
+
* `CI_PIPELINE_URL` verbatim;
|
|
177
|
+
* - the local mint says `local` in a field instead of only in the id's
|
|
178
|
+
* spelling.
|
|
179
|
+
*
|
|
180
|
+
* An explicit id still gets an origin when it matches the id the environment
|
|
181
|
+
* itself is advertising (a CI job passing `--run-id $GITHUB_RUN_ID`);
|
|
182
|
+
* otherwise the caller's id is recorded without one — guessing an origin the
|
|
183
|
+
* caller did not state would be exactly the inference this field exists to
|
|
184
|
+
* remove.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveRunId(
|
|
187
|
+
explicit?: string,
|
|
188
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
189
|
+
): { runId: string; runOrigin?: RunOrigin } {
|
|
190
|
+
const githubOrigin = (id: string): RunOrigin => ({
|
|
191
|
+
forge: "github",
|
|
192
|
+
...(env.GITHUB_REPOSITORY ? { repo: env.GITHUB_REPOSITORY } : {}),
|
|
193
|
+
...(env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY
|
|
194
|
+
? { url: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${id}` }
|
|
195
|
+
: {}),
|
|
196
|
+
});
|
|
197
|
+
const gitlabOrigin = (): RunOrigin => ({
|
|
198
|
+
forge: "gitlab",
|
|
199
|
+
...(env.CI_PROJECT_PATH ? { repo: env.CI_PROJECT_PATH } : {}),
|
|
200
|
+
...(env.CI_PIPELINE_URL ? { url: env.CI_PIPELINE_URL } : {}),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (explicit) {
|
|
204
|
+
if (env.GITHUB_RUN_ID && explicit === env.GITHUB_RUN_ID) return { runId: explicit, runOrigin: githubOrigin(explicit) };
|
|
205
|
+
if (env.CI_PIPELINE_ID && explicit === env.CI_PIPELINE_ID) return { runId: explicit, runOrigin: gitlabOrigin() };
|
|
206
|
+
return { runId: explicit };
|
|
207
|
+
}
|
|
208
|
+
if (env.GITHUB_RUN_ID) return { runId: env.GITHUB_RUN_ID, runOrigin: githubOrigin(env.GITHUB_RUN_ID) };
|
|
209
|
+
if (env.CI_PIPELINE_ID) return { runId: env.CI_PIPELINE_ID, runOrigin: gitlabOrigin() };
|
|
210
|
+
return { runId: `local-${Date.now()}`, runOrigin: { forge: "local" } };
|
|
211
|
+
}
|
|
212
|
+
|
|
135
213
|
/**
|
|
136
214
|
* Append one immutable release record to the `<env>` ledger on the
|
|
137
215
|
* `chant/lifecycle` orphan branch. Throws `InvalidReleaseRecordError` before
|
|
@@ -75,6 +75,18 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
|
|
|
75
75
|
* per #1290 — a later step's reference into this one's output.
|
|
76
76
|
*/
|
|
77
77
|
returns?: z.ZodType<Return>;
|
|
78
|
+
/**
|
|
79
|
+
* Names of top-level args whose values identify what the activity's effect
|
|
80
|
+
* touches in the estate (#2022) — an entity id, a resource address, a
|
|
81
|
+
* target endpoint — as opposed to scope (`env`, a stack name) or mechanics
|
|
82
|
+
* (a timeout, a mode). op.json's IR resolves these per step into
|
|
83
|
+
* `OpIRActivityStep.entities`, so a renderer can join a step to the estate
|
|
84
|
+
* nodes it touches instead of inventing joins off scope fields. Optional
|
|
85
|
+
* and partial by design, like the contract itself: an activity whose
|
|
86
|
+
* touched set is not spelled in its args (e.g. an applier that derives
|
|
87
|
+
* stack members internally) simply declares none.
|
|
88
|
+
*/
|
|
89
|
+
entities?: string[];
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/** Declare an activity contract. */
|
|
@@ -82,12 +94,14 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
|
|
|
82
94
|
name: string,
|
|
83
95
|
args: ArgsSchema,
|
|
84
96
|
returns?: ReturnSchema,
|
|
97
|
+
opts?: { entities?: string[] },
|
|
85
98
|
): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
|
|
86
99
|
return {
|
|
87
100
|
[CONTRACT_BRAND]: true,
|
|
88
101
|
name,
|
|
89
102
|
args,
|
|
90
103
|
...(returns ? { returns } : {}),
|
|
104
|
+
...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
|
|
91
105
|
} as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -3,10 +3,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import {
|
|
6
|
+
CARVE_MANIFEST_VERSION,
|
|
6
7
|
carveManifestPath,
|
|
7
8
|
listCarveManifests,
|
|
8
9
|
readCarveManifest,
|
|
9
10
|
resolveCarveManifest,
|
|
11
|
+
resolveManifestFilePath,
|
|
10
12
|
updateCarveManifest,
|
|
11
13
|
writeCarveManifest,
|
|
12
14
|
type CarveManifest,
|
|
@@ -89,6 +91,56 @@ describe("carve manifest", () => {
|
|
|
89
91
|
});
|
|
90
92
|
});
|
|
91
93
|
|
|
94
|
+
test("records file paths relative to its own directory, so the tree can move (#2039)", () => {
|
|
95
|
+
withDir((dir) => {
|
|
96
|
+
const m = manifest("aws_s3_bucket.assets");
|
|
97
|
+
m.emit = { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" };
|
|
98
|
+
m.bridge = {
|
|
99
|
+
written: [join(dir, "aws_s3_bucket-assets-runbook.md"), join(dir, "..", "api.tf")],
|
|
100
|
+
appliedInPlace: false,
|
|
101
|
+
patch: join(dir, "aws_s3_bucket-assets-bridge.patch"),
|
|
102
|
+
at: "t",
|
|
103
|
+
};
|
|
104
|
+
const path = writeCarveManifest(dir, m);
|
|
105
|
+
const read = readCarveManifest(path)!;
|
|
106
|
+
expect(read.version).toBe(CARVE_MANIFEST_VERSION);
|
|
107
|
+
expect(read.emit!.files).toEqual([join("src", "assets.ts")]);
|
|
108
|
+
expect(read.bridge!.written).toEqual(["aws_s3_bucket-assets-runbook.md", join("..", "api.tf")]);
|
|
109
|
+
expect(read.bridge!.patch).toBe("aws_s3_bucket-assets-bridge.patch");
|
|
110
|
+
// `from`/`statePath` locate the estate, a tree the manifest does not
|
|
111
|
+
// travel with — they stay absolute.
|
|
112
|
+
expect(read.from).toBe("/estate");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("resolveManifestFilePath joins relative entries and passes absolute (v1) entries through", () => {
|
|
117
|
+
expect(resolveManifestFilePath("/moved/carveout", join("src", "assets.ts"))).toBe(join("/moved/carveout", "src", "assets.ts"));
|
|
118
|
+
expect(resolveManifestFilePath("/moved/carveout", "/original/carveout/src/assets.ts")).toBe("/original/carveout/src/assets.ts");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("reads a version-1 manifest (absolute paths) and migrates it on update", () => {
|
|
122
|
+
withDir((dir) => {
|
|
123
|
+
const path = carveManifestPath(dir, "aws_s3_bucket.assets");
|
|
124
|
+
const v1: CarveManifest = {
|
|
125
|
+
...manifest("aws_s3_bucket.assets"),
|
|
126
|
+
version: 1,
|
|
127
|
+
emit: { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" },
|
|
128
|
+
};
|
|
129
|
+
writeFileSync(path, JSON.stringify(v1, null, 2) + "\n");
|
|
130
|
+
// Readable as-is, absolute paths intact.
|
|
131
|
+
expect(readCarveManifest(path)!.emit!.files).toEqual([join(dir, "src", "assets.ts")]);
|
|
132
|
+
|
|
133
|
+
// Any update rewrites the whole manifest to the version-2 contract.
|
|
134
|
+
updateCarveManifest(dir, "aws_s3_bucket.assets", {
|
|
135
|
+
bridge: { written: [join(dir, "runbook.md")], appliedInPlace: false, at: "t" },
|
|
136
|
+
});
|
|
137
|
+
const migrated = readCarveManifest(path)!;
|
|
138
|
+
expect(migrated.version).toBe(CARVE_MANIFEST_VERSION);
|
|
139
|
+
expect(migrated.emit!.files).toEqual([join("src", "assets.ts")]);
|
|
140
|
+
expect(migrated.bridge!.written).toEqual(["runbook.md"]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
92
144
|
test("update patches an existing manifest and no-ops on a missing one", () => {
|
|
93
145
|
withDir((dir) => {
|
|
94
146
|
expect(updateCarveManifest(dir, "aws_s3_bucket.assets", { bridge: { written: [], appliedInPlace: false, at: "t" } })).toBeUndefined();
|