@intentius/chant 0.124.33 → 0.124.35
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +284 -81
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +24 -0
- package/dist/cli/registry.d.ts +17 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +9 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +18 -3
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +226 -224
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +20 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +130 -0
- package/dist/workspace/checks/security.d.ts +16 -1
- package/dist/workspace/checks/security.d.ts.map +1 -1
- package/dist/workspace/checks/security.js +4 -4
- package/dist/workspace/checks.d.ts +1 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +18 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +9 -2
- package/dist/workspace/declaration.schema.json +28 -0
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/reason-codes.d.ts +9 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +10 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +302 -91
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +37 -2
- package/src/cli/parse-args.ts +18 -0
- package/src/cli/registry.ts +17 -1
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +17 -2
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +20 -2
- package/src/workspace/checks/ci-workflow.test.ts +160 -0
- package/src/workspace/checks/ci-workflow.ts +135 -0
- package/src/workspace/checks/security.ts +4 -4
- package/src/workspace/checks.ts +4 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-report-url.test.ts +6 -1
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +28 -0
- package/src/workspace/declaration.ts +32 -3
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/init.ts +2 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +10 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/release-workflow.ts +167 -0
- package/src/workspace/releases.schema.json +30 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
emptyVerifier,
|
|
14
14
|
gateNamesOf,
|
|
15
15
|
planReleaseChangeSet,
|
|
16
|
+
planReleaseSet,
|
|
16
17
|
provisionalMembers,
|
|
17
18
|
RELEASE_GATE_OP,
|
|
18
19
|
} from "./release-gate";
|
|
@@ -162,6 +163,52 @@ describe("planReleaseChangeSet", () => {
|
|
|
162
163
|
});
|
|
163
164
|
});
|
|
164
165
|
|
|
166
|
+
describe("planReleaseSet: a dependent plans against its upstream's planned outputs (#4168 step 4)", () => {
|
|
167
|
+
const r1 = release(sha("7"));
|
|
168
|
+
const reg = () => {
|
|
169
|
+
const r = registry();
|
|
170
|
+
r.register({
|
|
171
|
+
kind: "tf",
|
|
172
|
+
async run() { return {}; },
|
|
173
|
+
async plan(_ctx: unknown, input: { name: string; vpc?: unknown }) {
|
|
174
|
+
return {
|
|
175
|
+
member: input.name,
|
|
176
|
+
planDigest: sha(input.vpc === "vpc-new" ? "b" : input.vpc === undefined ? "c" : "a"),
|
|
177
|
+
outputs: input.name === "net" ? { known: { id: "vpc-new" }, unknown: ["arn"] } : undefined,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
async outputs() { return { id: "vpc-old", arn: "arn:old" }; },
|
|
181
|
+
} as never);
|
|
182
|
+
return r;
|
|
183
|
+
};
|
|
184
|
+
const net: DriverComponent = { name: "net", deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "net" }] }] };
|
|
185
|
+
const svc = (ref: string): DriverComponent => ({
|
|
186
|
+
name: "svc",
|
|
187
|
+
dependsOn: ["net"],
|
|
188
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: ref } } }] }],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an upstream the promote plans is read from its plan, and the read is kept", async () => {
|
|
192
|
+
const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("id"), net], digests: new Map(), registry: reg(), current: new Map() });
|
|
193
|
+
expect(set.doc.members.find((m) => m.member === "svc")?.planDigest).toBe(sha("b"));
|
|
194
|
+
expect(set.reads.svc).toEqual({ net: { id: "vpc-new" } });
|
|
195
|
+
expect(set.plannedOutputs).toEqual({ net: ["id"] });
|
|
196
|
+
expect(set.unknownInputs).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an output the upstream's plan can't say is an unknown input", async () => {
|
|
200
|
+
const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("arn"), net], digests: new Map(), registry: reg(), current: new Map() });
|
|
201
|
+
expect(set.unknownInputs).toEqual({ svc: ["net.arn"] });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an upstream outside the promote is read as it stands", async () => {
|
|
205
|
+
const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("id"), net], digests: new Map(), registry: reg(), current: new Map(), only: new Set(["svc"]), seededOutputs: { net: { id: "vpc-old" } } });
|
|
206
|
+
expect(set.doc.members.map((m) => m.member)).toEqual(["svc"]);
|
|
207
|
+
expect(set.reads.svc).toEqual({ net: { id: "vpc-old" } });
|
|
208
|
+
expect(set.plannedOutputs).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
165
212
|
describe("empty change sets", () => {
|
|
166
213
|
const r1 = release(sha("7"));
|
|
167
214
|
const emptyDoc = (): ChangeSetDocument =>
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
* planned by that capability, as a pull request's plan is. A plan that
|
|
40
40
|
* returns no change-set part is a hole, since nothing says what it changes.
|
|
41
41
|
*
|
|
42
|
+
* A dependency the promote also deploys is read from its plan, not from the
|
|
43
|
+
* estate (#4168 step 4, the rule #3999 gave a pull request): a dependent is
|
|
44
|
+
* planned against the outputs its upstream's plan says it will expose, and a
|
|
45
|
+
* reference the plan can't say is listed in `unknownInputs`. Any other
|
|
46
|
+
* dependency is read as it stands. What each component was planned against is
|
|
47
|
+
* kept in `reads`, so the promote can hold it back when an upstream applies
|
|
48
|
+
* as another value (`./promote-rounds.ts`).
|
|
49
|
+
*
|
|
42
50
|
* Any other component is a `chant` member compared with what the
|
|
43
51
|
* environment runs now: its latest deploy record and the release that record
|
|
44
52
|
* names. The artifact digest, each template the release's target records for
|
|
@@ -52,8 +60,8 @@
|
|
|
52
60
|
* recorded since (#4035): its record names what ran before that run.
|
|
53
61
|
*/
|
|
54
62
|
|
|
55
|
-
import type { CapabilityRegistry } from "./capability";
|
|
56
|
-
import { deployContext, resolveComponentGraph, resolveStepInput, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
|
|
63
|
+
import type { CapabilityRegistry, PlannedOutputs } from "./capability";
|
|
64
|
+
import { deployContext, resolveComponentGraph, resolveStepInput, unresolvedWiringReferences, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
|
|
57
65
|
import { componentPlanDigest } from "./gate-plan";
|
|
58
66
|
import { gateName, RELEASE_GATE_OP } from "../op/gate-name";
|
|
59
67
|
import { capabilitySteps, planContext, readComponentOutputs } from "./wave-plan";
|
|
@@ -123,6 +131,23 @@ export interface ReleaseChangeSetInput {
|
|
|
123
131
|
seededOutputs?: Record<string, Record<string, unknown>>;
|
|
124
132
|
/** The chant version to record in the document. */
|
|
125
133
|
chant?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Plan only these components (a later round or wave, ./promote-rounds.ts).
|
|
136
|
+
* The others are not planned; a dependency among them is read from
|
|
137
|
+
* `seededOutputs`, which then holds what the run left. Default: every one.
|
|
138
|
+
*/
|
|
139
|
+
only?: ReadonlySet<string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A release's change set for an environment, and what each component was planned against. */
|
|
143
|
+
export interface ReleasePlan {
|
|
144
|
+
doc: ChangeSetDocument;
|
|
145
|
+
/** Per planned component, the outputs of its dependencies as its plan read them. */
|
|
146
|
+
reads: Record<string, Record<string, Record<string, unknown>>>;
|
|
147
|
+
/** Per dependency read from its plan, the names of the outputs the plan knows, sorted. Absent when none. */
|
|
148
|
+
plannedOutputs?: Record<string, string[]>;
|
|
149
|
+
/** Per component, the references to a promoted upstream that no value stands for until it applies, sorted. Absent when none. */
|
|
150
|
+
unknownInputs?: Record<string, string[]>;
|
|
126
151
|
}
|
|
127
152
|
|
|
128
153
|
const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -240,65 +265,105 @@ export function chantComponentPart(input: {
|
|
|
240
265
|
* that fails to plan: it becomes a failed member.
|
|
241
266
|
*/
|
|
242
267
|
export async function planReleaseChangeSet(input: ReleaseChangeSetInput): Promise<ChangeSetDocument> {
|
|
268
|
+
return (await planReleaseSet(input)).doc;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* {@link planReleaseChangeSet}, with what each component read. Components are
|
|
273
|
+
* planned a wave at a time, so a dependency the promote deploys has planned
|
|
274
|
+
* before its dependents read its outputs.
|
|
275
|
+
*/
|
|
276
|
+
export async function planReleaseSet(input: ReleaseChangeSetInput): Promise<ReleasePlan> {
|
|
243
277
|
const { release, env, registry } = input;
|
|
244
278
|
const components = [...input.components];
|
|
245
279
|
const byName = new Map(components.map((c) => [c.name, c]));
|
|
246
280
|
const outputs: Record<string, Record<string, unknown>> = { ...(input.seededOutputs ?? {}) };
|
|
247
281
|
const read = new Set(Object.keys(outputs));
|
|
248
282
|
const parts: ChangeSetPart[] = [];
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
283
|
+
const plannedBy = new Map<string, PlannedOutputs | undefined>();
|
|
284
|
+
const planned: Record<string, string[]> = {};
|
|
285
|
+
const unknownInputs: Record<string, string[]> = {};
|
|
286
|
+
const reads: ReleasePlan["reads"] = {};
|
|
287
|
+
const inPlan = (name: string): boolean => input.only === undefined || input.only.has(name);
|
|
288
|
+
const isPlannable = (c: DriverComponent): boolean =>
|
|
289
|
+
capabilitySteps(c.deploy).some((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
|
|
290
|
+
const { waves } = resolveComponentGraph(components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
|
|
291
|
+
for (const wave of waves) {
|
|
292
|
+
const planning = wave.filter(inPlan);
|
|
293
|
+
for (const name of planning) {
|
|
294
|
+
const component = byName.get(name)!;
|
|
295
|
+
const digest = input.digests.get(name) ?? "";
|
|
296
|
+
const plannable = capabilitySteps(component.deploy).filter((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
|
|
297
|
+
if (plannable.length === 0) {
|
|
298
|
+
parts.push(chantComponentPart({ component, release, env, digest, current: input.current.get(name), unsettled: input.unsettled?.get(name) }));
|
|
299
|
+
continue;
|
|
265
300
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
301
|
+
const ctx = deployContext({ env }, name);
|
|
302
|
+
try {
|
|
303
|
+
for (const dep of component.dependsOn ?? []) {
|
|
304
|
+
if (read.has(dep) || !byName.has(dep)) continue;
|
|
305
|
+
read.add(dep);
|
|
306
|
+
// A dependency this promote plans exposes what its plan says (#3999).
|
|
307
|
+
if (plannedBy.has(dep) && isPlannable(byName.get(dep)!)) {
|
|
308
|
+
const known = plannedBy.get(dep)?.known ?? {};
|
|
309
|
+
outputs[dep] = { ...known };
|
|
310
|
+
planned[dep] = Object.keys(known).sort();
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const found = await readComponentOutputs(byName.get(dep)!, registry, deployContext({ env }, dep), outputs);
|
|
314
|
+
if (found) outputs[dep] = found;
|
|
315
|
+
}
|
|
316
|
+
reads[name] = Object.fromEntries((component.dependsOn ?? []).filter((d) => d in outputs).map((d) => [d, structuredClone(outputs[d])]));
|
|
317
|
+
const unknown = new Set<string>();
|
|
318
|
+
let exposes: PlannedOutputs | undefined;
|
|
319
|
+
for (const step of plannable) {
|
|
320
|
+
const { kind, ...rest } = step;
|
|
321
|
+
for (const r of unresolvedWiringReferences(rest, outputs)) if (plannedBy.has(r.source) && !r.publish) unknown.add(r.ref);
|
|
322
|
+
const result = await registry.resolve(kind).plan!(planContext(ctx, rest, outputs), resolveStepInput(rest, {}, outputs) as never);
|
|
323
|
+
if (result.outputs) exposes = { known: { ...(exposes?.known ?? {}), ...result.outputs.known }, unknown: [...new Set([...(exposes?.unknown ?? []), ...result.outputs.unknown])].sort() };
|
|
324
|
+
parts.push(
|
|
325
|
+
result.changeSet ?? {
|
|
326
|
+
member: {
|
|
327
|
+
member: result.member,
|
|
328
|
+
lexicon: "chant",
|
|
329
|
+
planner: "chant",
|
|
330
|
+
status: "planned",
|
|
331
|
+
planDigest: result.planDigest,
|
|
332
|
+
holes: [{ address: result.member, reason: `its ${kind} plan describes no changes, so what it changes is unknown` }],
|
|
333
|
+
},
|
|
334
|
+
entries: [],
|
|
278
335
|
},
|
|
279
|
-
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
plannedBy.set(name, exposes);
|
|
339
|
+
if (unknown.size > 0) unknownInputs[name] = [...unknown].sort();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
plannedBy.set(name, undefined);
|
|
342
|
+
parts.push({
|
|
343
|
+
member: {
|
|
344
|
+
member: name,
|
|
345
|
+
lexicon: "chant",
|
|
346
|
+
planner: "chant",
|
|
347
|
+
status: "failed",
|
|
348
|
+
error: err instanceof Error ? err.message : String(err),
|
|
349
|
+
planDigest: null,
|
|
350
|
+
holes: [],
|
|
280
351
|
},
|
|
281
|
-
|
|
352
|
+
entries: [],
|
|
353
|
+
});
|
|
282
354
|
}
|
|
283
|
-
} catch (err) {
|
|
284
|
-
parts.push({
|
|
285
|
-
member: {
|
|
286
|
-
member: name,
|
|
287
|
-
lexicon: "chant",
|
|
288
|
-
planner: "chant",
|
|
289
|
-
status: "failed",
|
|
290
|
-
error: err instanceof Error ? err.message : String(err),
|
|
291
|
-
planDigest: null,
|
|
292
|
-
holes: [],
|
|
293
|
-
},
|
|
294
|
-
entries: [],
|
|
295
|
-
});
|
|
296
355
|
}
|
|
297
356
|
}
|
|
298
|
-
|
|
357
|
+
const doc = composeChangeSet(parts, {
|
|
299
358
|
...(input.chant !== undefined ? { chant: input.chant } : {}),
|
|
300
359
|
planned: { releaseDigest: release.releaseDigest, environment: env },
|
|
301
360
|
});
|
|
361
|
+
return {
|
|
362
|
+
doc,
|
|
363
|
+
reads,
|
|
364
|
+
...(Object.keys(planned).length > 0 ? { plannedOutputs: planned } : {}),
|
|
365
|
+
...(Object.keys(unknownInputs).length > 0 ? { unknownInputs } : {}),
|
|
366
|
+
};
|
|
302
367
|
}
|
|
303
368
|
|
|
304
369
|
/** The members a release gate refuses: provisional ones, which no approval of the document covers (`change-set.mdx`). */
|
|
@@ -7,7 +7,7 @@ import { describe, test, expect } from "vitest";
|
|
|
7
7
|
import { contentDigest } from "../content-digest";
|
|
8
8
|
import { makeRelease, templateBytesDigest } from "../lifecycle/release-store";
|
|
9
9
|
import type { DriverComponent } from "./driver";
|
|
10
|
-
import { isImplicitRelease, pinReleaseTemplates, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
|
|
10
|
+
import { isImplicitRelease, pinReleaseTemplates, releaseTemplatesDigest, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
|
|
11
11
|
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
12
12
|
import type { BuildArchiveManifest } from "./verbs/build-archive";
|
|
13
13
|
import { withTestDir } from "@intentius/chant-test-utils";
|
|
@@ -210,3 +210,26 @@ describe("removePinnedTemplates (#4045)", () => {
|
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("releaseTemplatesDigest (#4222)", () => {
|
|
215
|
+
const target = {
|
|
216
|
+
env: "staging",
|
|
217
|
+
params: [],
|
|
218
|
+
templates: [
|
|
219
|
+
{ component: "infra", path: "b.json", digest: "sha256:b" },
|
|
220
|
+
{ component: "infra", path: "a.json", digest: "sha256:a" },
|
|
221
|
+
{ component: "app", path: "c.json", digest: "sha256:c" },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
test("digests the component's templates sorted by path, whatever order the target lists them", () => {
|
|
226
|
+
const reordered = { ...target, templates: [target.templates[1], target.templates[2], target.templates[0]] };
|
|
227
|
+
expect(releaseTemplatesDigest(target, "infra")).toBe(contentDigest("a.json\tsha256:a\nb.json\tsha256:b\n"));
|
|
228
|
+
expect(releaseTemplatesDigest(reordered, "infra")).toBe(releaseTemplatesDigest(target, "infra"));
|
|
229
|
+
expect(releaseTemplatesDigest(target, "app")).not.toBe(releaseTemplatesDigest(target, "infra"));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("is null for a component the target records no template for", () => {
|
|
233
|
+
expect(releaseTemplatesDigest(target, "worker")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -31,6 +31,7 @@ import { templateBytesDigest, type Release, type ReleaseTarget } from "../lifecy
|
|
|
31
31
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
32
32
|
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
33
33
|
import type { BuildArchiveManifest } from "./verbs/build-archive";
|
|
34
|
+
import { contentDigest } from "../content-digest";
|
|
34
35
|
|
|
35
36
|
/** Where a promote under a release writes the recorded templates, relative to the member's directory. */
|
|
36
37
|
export const RELEASE_TEMPLATES_DIR = ".chant/release-templates";
|
|
@@ -56,6 +57,20 @@ export function targetFor(release: Release, env: string): { target: ReleaseTarge
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The digest a release record carries for a component with no artifact
|
|
62
|
+
* (#4222): `sha256:` over the paths and digests of the templates `target`
|
|
63
|
+
* records for `component`, sorted by path. A template-only component (a
|
|
64
|
+
* `cfn-deploy` with no publish step) deployed under a release is recorded with
|
|
65
|
+
* it, so promote and rollback under a release find what the environment runs.
|
|
66
|
+
* Null when the target records no template for the component.
|
|
67
|
+
*/
|
|
68
|
+
export function releaseTemplatesDigest(target: ReleaseTarget, component: string): string | null {
|
|
69
|
+
const own = target.templates.filter((t) => t.component === component).map((t) => `${t.path}\t${t.digest}`).sort();
|
|
70
|
+
if (own.length === 0) return null;
|
|
71
|
+
return contentDigest(`${own.join("\n")}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
/** Whether a release is of a project with no components (#4046): no artifact, and every template it records has `component: null`. */
|
|
60
75
|
export function isImplicitRelease(release: Release): boolean {
|
|
61
76
|
const templates = release.targets.flatMap((t) => t.templates);
|
package/src/deep-observation.ts
CHANGED
|
@@ -334,6 +334,14 @@ export interface DeepNormalizationHooks {
|
|
|
334
334
|
* or a value-derived hash.
|
|
335
335
|
*/
|
|
336
336
|
mask?(node: DeepNode): boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Rewrite one raw property tree before anything else reads it, on both
|
|
339
|
+
* sides. For a difference no per-node hook can reach, such as a map whose
|
|
340
|
+
* keys are references: an ARM `identity.userAssignedIdentities` is keyed by
|
|
341
|
+
* `[resourceId(...)]` in source and by the evaluated resource id live
|
|
342
|
+
* (#4231). Return the tree to compare; leave anything else as it was.
|
|
343
|
+
*/
|
|
344
|
+
prepare?(tree: Record<string, unknown>, context: { entityType: string; side: DeepSide }): Record<string, unknown>;
|
|
337
345
|
}
|
|
338
346
|
|
|
339
347
|
/** Everything the pass needs besides the tree itself. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Side-by-side stacks keep their exports and imports (#4202). The live overlay
|
|
5
|
+
* builds the declared canvas per stack, and that canvas dropped the member's
|
|
6
|
+
* exports, so a workspace viewer had nothing to join another member's import
|
|
7
|
+
* to once an environment was picked.
|
|
8
|
+
*/
|
|
9
|
+
vi.mock("./discovery/index", () => ({ discover: vi.fn(async () => ({ entities: new Map(), errors: [] })) }));
|
|
10
|
+
vi.mock("./graph-ir", () => ({
|
|
11
|
+
buildGraphIr: vi.fn(() => ({
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: "webAlb", kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", lexicon: "aws" },
|
|
14
|
+
{ id: "zoneId", kind: "AWS::CloudFormation::Parameter", lexicon: "aws" },
|
|
15
|
+
],
|
|
16
|
+
edges: [],
|
|
17
|
+
groups: {},
|
|
18
|
+
exports: [{ name: "awsEndpoint", node: "webAlb", attr: "DNSName" }],
|
|
19
|
+
imports: [{ name: "zoneId", node: "zoneId" }],
|
|
20
|
+
})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { buildDeclaredPerStack } = await import("./graph-declared");
|
|
24
|
+
|
|
25
|
+
describe("buildDeclaredPerStack keeps exports and imports (#4202)", () => {
|
|
26
|
+
test("an export's node is qualified like the nodes, and its name is unchanged", async () => {
|
|
27
|
+
const ir = await buildDeclaredPerStack([{ name: "three-cloud-aws", src: "src" }], "/project");
|
|
28
|
+
expect(ir.nodes.map((n) => n.id)).toContain("three-cloud-aws::webAlb");
|
|
29
|
+
expect(ir.exports).toEqual([{ name: "awsEndpoint", node: "three-cloud-aws::webAlb", attr: "DNSName" }]);
|
|
30
|
+
expect(ir.imports).toEqual([{ name: "zoneId", node: "three-cloud-aws::zoneId" }]);
|
|
31
|
+
const ids = new Set(ir.nodes.map((n) => n.id));
|
|
32
|
+
for (const e of ir.exports ?? []) expect(ids.has(e.node!)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a stack without src brings no exports", async () => {
|
|
36
|
+
const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], "/project");
|
|
37
|
+
expect(ir.exports).toBeUndefined();
|
|
38
|
+
expect(ir.imports).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -84,3 +84,21 @@ describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
|
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("buildDeclaredPerStack takes edges from the lexicon hooks (#4232)", () => {
|
|
89
|
+
test("withEdges sees each stack's entities by bare key, and its edges are qualified with the stack", async () => {
|
|
90
|
+
const seen: string[][] = [];
|
|
91
|
+
const ir = await buildDeclaredPerStack(
|
|
92
|
+
[{ name: "network", src: "network/src" }, { name: "app", src: "app/src" }],
|
|
93
|
+
dir,
|
|
94
|
+
(g, entities) => {
|
|
95
|
+
seen.push([...entities.keys()].sort());
|
|
96
|
+
const [only] = g.nodes;
|
|
97
|
+
return only ? { ...g, edges: [...g.edges, { from: only.id, to: only.id, kind: "ref", viaAttr: "self" }] } : g;
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
expect(seen).toEqual([["vpc"], ["service"]]);
|
|
101
|
+
expect(ir.edges).toContainEqual({ from: "network::vpc", to: "network::vpc", kind: "ref", viaAttr: "self" });
|
|
102
|
+
expect(ir.edges).toContainEqual({ from: "app::service", to: "app::service", kind: "ref", viaAttr: "self" });
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/graph-declared.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { discover } from "./discovery/index";
|
|
3
|
+
import type { Declarable } from "./declarable";
|
|
3
4
|
import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -13,15 +14,25 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
|
|
|
13
14
|
* that live side, build each stack's `src` in isolation and qualify its node ids
|
|
14
15
|
* and edge endpoints the same way. Stacks are merged into one graph.
|
|
15
16
|
*
|
|
17
|
+
* Each stack's exports and imports come along with their `node` qualified the
|
|
18
|
+
* same way (#4202), so a workspace viewer can still match an import in another
|
|
19
|
+
* member to an export here once an environment is picked.
|
|
20
|
+
*
|
|
16
21
|
* A stack without `src` contributes nothing here — it has no declared source to
|
|
17
22
|
* scope to (its live nodes still show as foreign in the overlay).
|
|
23
|
+
*
|
|
24
|
+
* `withEdges` adds what the lexicons' `graphEdges` hooks resolve to each
|
|
25
|
+
* stack's graph before its ids are qualified (#4232).
|
|
18
26
|
*/
|
|
19
27
|
export async function buildDeclaredPerStack(
|
|
20
28
|
stacks: Array<{ name: string; src?: string }>,
|
|
21
29
|
projectPath: string,
|
|
30
|
+
withEdges?: (ir: GraphIR, entities: Map<string, Declarable>) => GraphIR,
|
|
22
31
|
): Promise<GraphIR> {
|
|
23
32
|
const nodes: IRNode[] = [];
|
|
24
33
|
const edges: GraphIR["edges"] = [];
|
|
34
|
+
const exports: NonNullable<GraphIR["exports"]> = [];
|
|
35
|
+
const imports: NonNullable<GraphIR["imports"]> = [];
|
|
25
36
|
// Which nodes belong to which stack (#1433). This is not derived or guessed:
|
|
26
37
|
// the stack is declared in config and every node here is being renamed with
|
|
27
38
|
// its name on the line below. The membership was always known and thrown
|
|
@@ -30,7 +41,9 @@ export async function buildDeclaredPerStack(
|
|
|
30
41
|
const byStack: Record<string, string[]> = {};
|
|
31
42
|
for (const st of stacks) {
|
|
32
43
|
if (!st.src) continue;
|
|
33
|
-
const
|
|
44
|
+
const { entities } = await discover(resolve(projectPath, st.src));
|
|
45
|
+
const built = buildGraphIr(entities, projectPath);
|
|
46
|
+
const g = withEdges ? withEdges(built, entities) : built;
|
|
34
47
|
const q = (id: string) => `${st.name}::${id}`;
|
|
35
48
|
for (const n of g.nodes) {
|
|
36
49
|
const id = q(n.id);
|
|
@@ -38,8 +51,10 @@ export async function buildDeclaredPerStack(
|
|
|
38
51
|
(byStack[st.name] ??= []).push(id);
|
|
39
52
|
}
|
|
40
53
|
for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
|
|
54
|
+
for (const e of g.exports ?? []) exports.push({ ...e, ...(e.node ? { node: q(e.node) } : {}) });
|
|
55
|
+
for (const i of g.imports ?? []) imports.push({ ...i, node: q(i.node) });
|
|
41
56
|
}
|
|
42
57
|
for (const ids of Object.values(byStack)) ids.sort();
|
|
43
58
|
const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
|
|
44
|
-
return { nodes, edges, groups };
|
|
59
|
+
return { nodes, edges, groups, ...(exports.length ? { exports } : {}), ...(imports.length ? { imports } : {}) };
|
|
45
60
|
}
|
package/src/graph-detail.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { applyDetail, DETAIL, detailInertNotice } from "./graph-detail";
|
|
3
|
-
import type
|
|
3
|
+
import { overlayGraphs, sourceOverlayGraphs, type GraphIR } from "./graph-ir";
|
|
4
4
|
|
|
5
5
|
// A small graph: a gcp vpc/subnet pair plus a k8s namespace and deployment that
|
|
6
6
|
// came from one composite instance ("db"), with the deployment referencing the
|
|
@@ -294,3 +294,70 @@ describe("detailInertNotice (#1489)", () => {
|
|
|
294
294
|
expect(detailInertNotice(edgeless, detailed)).toContain("no edges at all");
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("T1 rolls a composite's overlay status up from its members (#4227)", () => {
|
|
299
|
+
// Two stacks' worth of one FargateAlb, keyed `${stack}::${logicalId}` as a
|
|
300
|
+
// multi-stack live read keys them (#1162).
|
|
301
|
+
const member = (id: string, attrs: Record<string, unknown> = {}) => ({
|
|
302
|
+
id: `three-cloud-aws::${id}`,
|
|
303
|
+
kind: "Resource",
|
|
304
|
+
lexicon: "aws",
|
|
305
|
+
compositeParent: "FargateAlb",
|
|
306
|
+
compositeInstance: "web",
|
|
307
|
+
attrs,
|
|
308
|
+
});
|
|
309
|
+
const declared: GraphIR = {
|
|
310
|
+
nodes: [member("webAlb"), member("webService"), member("webTaskDef")],
|
|
311
|
+
edges: [],
|
|
312
|
+
groups: { byLexicon: {} },
|
|
313
|
+
};
|
|
314
|
+
const live = (ids: string[], extra: GraphIR["nodes"] = []): GraphIR => ({
|
|
315
|
+
nodes: [...ids.map((id) => ({ id: `three-cloud-aws::${id}`, kind: "Resource", lexicon: "aws", attrs: {} })), ...extra],
|
|
316
|
+
edges: [],
|
|
317
|
+
groups: { byLexicon: {} },
|
|
318
|
+
});
|
|
319
|
+
const web = (ir: GraphIR) => applyDetail(ir, DETAIL.COMPOSITES).nodes.find((n) => n.id === "web")!;
|
|
320
|
+
const all = ["webAlb", "webService", "webTaskDef"];
|
|
321
|
+
|
|
322
|
+
test("every member good gives good, on either anchor", () => {
|
|
323
|
+
for (const ir of [sourceOverlayGraphs(declared, live(all)), overlayGraphs(live(all), declared)]) {
|
|
324
|
+
expect(web(ir).attrs).toEqual({ members: 3, _status: "good" });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("an absent member makes the composite pending, and is named", () => {
|
|
329
|
+
for (const ir of [sourceOverlayGraphs(declared, live(["webAlb", "webTaskDef"])), overlayGraphs(live(["webAlb", "webTaskDef"]), declared)]) {
|
|
330
|
+
expect(web(ir).attrs).toEqual({ members: 3, _status: "accent", _memberStatus: { accent: ["three-cloud-aws::webService"] } });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("an unobserved member gives neutral, and an absent one outranks it", () => {
|
|
335
|
+
const unobserved = { "three-cloud-aws::webTaskDef": { reason: "unsupported-kind" } } as never;
|
|
336
|
+
expect(web(sourceOverlayGraphs(declared, live(["webAlb", "webService"]), { unobserved })).attrs).toEqual({
|
|
337
|
+
members: 3,
|
|
338
|
+
_status: "neutral",
|
|
339
|
+
_memberStatus: { neutral: ["three-cloud-aws::webTaskDef"] },
|
|
340
|
+
});
|
|
341
|
+
expect(web(sourceOverlayGraphs(declared, live(["webAlb"]), { unobserved })).attrs).toEqual({
|
|
342
|
+
members: 3,
|
|
343
|
+
_status: "accent",
|
|
344
|
+
_memberStatus: { accent: ["three-cloud-aws::webService"], neutral: ["three-cloud-aws::webTaskDef"] },
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a foreign resource stays its own node and leaves the composite good", () => {
|
|
349
|
+
const foreign = { id: "three-cloud-aws::strayBucket", kind: "Bucket", lexicon: "aws", attrs: {} };
|
|
350
|
+
const ir = applyDetail(sourceOverlayGraphs(declared, live(all, [foreign])), DETAIL.COMPOSITES);
|
|
351
|
+
expect(ir.nodes.find((n) => n.id === "web")!.attrs).toEqual({ members: 3, _status: "good" });
|
|
352
|
+
expect(ir.nodes.find((n) => n.id === foreign.id)!.attrs).toEqual({ _status: "warn" });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a member painted with a status the rollup does not know is never hidden", () => {
|
|
356
|
+
const ir: GraphIR = { ...declared, nodes: [member("webAlb", { _status: "good" }), member("webService", { _status: "drifted" })] };
|
|
357
|
+
expect(web(ir).attrs).toEqual({ members: 2, _status: "drifted", _memberStatus: { drifted: ["three-cloud-aws::webService"] } });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a graph read without --overlay carries no _status on the composite", () => {
|
|
361
|
+
expect(web(declared).attrs).toEqual({ members: 3 });
|
|
362
|
+
});
|
|
363
|
+
});
|
package/src/graph-detail.ts
CHANGED
|
@@ -124,6 +124,34 @@ function toDeclarables(ir: GraphIR): GraphIR {
|
|
|
124
124
|
return { ...ir, nodes: ir.nodes.map((n) => ({ ...n, attrs: resourceAttrs(n.attrs) })) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Overlay statuses from the least to the most in need of attention: a
|
|
128
|
+
* composite takes its worst member's (#4227). An unknown status ranks above
|
|
129
|
+
* `warn`, so a status this list does not know yet is never hidden. */
|
|
130
|
+
const STATUS_RANK = ["good", "runtime", "neutral", "accent", "warn"];
|
|
131
|
+
|
|
132
|
+
function statusRank(status: string): number {
|
|
133
|
+
const i = STATUS_RANK.indexOf(status);
|
|
134
|
+
return i === -1 ? STATUS_RANK.length : i;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A collapsed composite's overlay paint, rolled up from its members (#4227):
|
|
138
|
+
* `_status` is the worst member status, and `_memberStatus` lists the members
|
|
139
|
+
* that are not `good` under their status. Nothing when no member carries a
|
|
140
|
+
* `_status`, as in a graph read without `--overlay`. */
|
|
141
|
+
function rollupStatus(members: IRNode[]): Record<string, unknown> {
|
|
142
|
+
const painted = members.filter((m) => typeof m.attrs._status === "string");
|
|
143
|
+
if (painted.length === 0) return {};
|
|
144
|
+
let worst = "good";
|
|
145
|
+
const notGood: Record<string, string[]> = {};
|
|
146
|
+
for (const m of painted) {
|
|
147
|
+
const status = m.attrs._status as string;
|
|
148
|
+
if (statusRank(status) > statusRank(worst)) worst = status;
|
|
149
|
+
if (status !== "good") (notGood[status] ??= []).push(m.id);
|
|
150
|
+
}
|
|
151
|
+
for (const ids of Object.values(notGood)) ids.sort();
|
|
152
|
+
return { _status: worst, ...(Object.keys(notGood).length > 0 ? { _memberStatus: notGood } : {}) };
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
/** T1 — collapse each composite instance to one node; internal edges disappear.
|
|
128
156
|
* Topology view: surviving plain nodes keep only overlay paint, not properties. */
|
|
129
157
|
function toComposites(ir: GraphIR): GraphIR {
|
|
@@ -169,7 +197,7 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
169
197
|
id: collapsedId.get(key)!,
|
|
170
198
|
kind: types.size === 1 ? [...types][0] : "Composite",
|
|
171
199
|
lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
|
|
172
|
-
attrs: { members: members.length },
|
|
200
|
+
attrs: { members: members.length, ...rollupStatus(members) },
|
|
173
201
|
});
|
|
174
202
|
}
|
|
175
203
|
|
package/src/graph-ir.ts
CHANGED
|
@@ -1078,13 +1078,21 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
|
|
|
1078
1078
|
* no live edges). Sorted; the live groups pass through unchanged.
|
|
1079
1079
|
*/
|
|
1080
1080
|
export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
1081
|
-
const
|
|
1081
|
+
const declaredById = new Map(declared.nodes.map((n) => [n.id, n]));
|
|
1082
1082
|
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
1083
1083
|
const unobserved = opts?.unobserved ?? {};
|
|
1084
1084
|
|
|
1085
|
-
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
1086
|
-
|
|
1087
|
-
|
|
1085
|
+
const nodes: IRNode[] = live.nodes.map((n) => {
|
|
1086
|
+
const d = declaredById.get(n.id);
|
|
1087
|
+
if (!d) return tagStatus(n, n.runtimeOwner ? "runtime" : "warn");
|
|
1088
|
+
// A managed node keeps the composite it was declared in, so `--detail 1`
|
|
1089
|
+
// collapses it with its siblings and rolls their status up (#4227).
|
|
1090
|
+
const membership = {
|
|
1091
|
+
...(d.compositeInstance ? { compositeInstance: d.compositeInstance } : {}),
|
|
1092
|
+
...(d.compositeParent ? { compositeParent: d.compositeParent } : {}),
|
|
1093
|
+
};
|
|
1094
|
+
return tagStatus({ ...n, ...membership }, "good");
|
|
1095
|
+
});
|
|
1088
1096
|
for (const n of declared.nodes) {
|
|
1089
1097
|
if (liveIds.has(n.id)) continue;
|
|
1090
1098
|
const u = unobserved[n.id];
|
|
@@ -168,7 +168,8 @@ const { execFileSync } = require("child_process");
|
|
|
168
168
|
const args = process.argv.slice(2);
|
|
169
169
|
const token = process.env.NODE_AUTH_TOKEN || "";
|
|
170
170
|
const regFile = ${JSON.stringify(log)} + ".registry.json";
|
|
171
|
-
fs.
|
|
171
|
+
const userconfig = process.env.NPM_CONFIG_USERCONFIG ? fs.readFileSync(process.env.NPM_CONFIG_USERCONFIG, "utf-8") : "";
|
|
172
|
+
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "", ...(userconfig ? { userconfig } : {}) }) + "\\n");
|
|
172
173
|
console.log("npm notice using //registry.example/:_authToken=" + token);
|
|
173
174
|
console.error("npm notice token " + token);
|
|
174
175
|
const reg = fs.existsSync(regFile) ? JSON.parse(fs.readFileSync(regFile, "utf-8")) : { packages: {} };
|
|
@@ -177,6 +178,9 @@ const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : arg
|
|
|
177
178
|
const split = (spec) => { const at = spec.lastIndexOf("@"); return [spec.slice(0, at), spec.slice(at + 1)]; };
|
|
178
179
|
if (args[0] === "publish") {
|
|
179
180
|
if (process.env.FAKE_NPM_FAIL && args[1] && args[1].includes(process.env.FAKE_NPM_FAIL)) { console.error("npm error 403 Forbidden"); process.exit(1); }
|
|
181
|
+
// A package whose trusted-publisher record is broken: only a token in the user config publishes it.
|
|
182
|
+
if (process.env.FAKE_NPM_NEEDAUTH && args[1] && args[1].includes(process.env.FAKE_NPM_NEEDAUTH) && !userconfig.includes("_authToken=")) { console.error("npm error code ENEEDAUTH"); process.exit(1); }
|
|
183
|
+
if (process.env.FAKE_NPM_TOKEN_NO_PROVENANCE && userconfig && args.includes("--provenance")) { console.error("npm error 422 Unprocessable Entity - provenance statement does not match the publisher"); process.exit(1); }
|
|
180
184
|
const text = fs.statSync(args[1]).isDirectory() ? fs.readFileSync(path.join(args[1], "package.json"), "utf-8") : execFileSync("tar", ["-xzOf", args[1], "package/package.json"], { encoding: "utf-8" });
|
|
181
185
|
const { name, version } = JSON.parse(text);
|
|
182
186
|
const p = reg.packages[name] || (reg.packages[name] = { versions: [], distTags: {} });
|