@intentius/chant 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +35 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +73 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +78 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +155 -6
- package/src/cli/handlers/graph.ts +124 -29
- package/src/cli/handlers/lifecycle.test.ts +410 -6
- package/src/cli/handlers/lifecycle.ts +475 -182
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +1216 -116
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +100 -20
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +115 -12
- package/src/index.ts +2 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +100 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +40 -20
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +55 -0
- package/src/stack-output.ts +41 -20
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import type { ParsedArgs } from "../registry";
|
|
3
3
|
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
4
4
|
import { AttrRef } from "../../attrref";
|
|
@@ -19,8 +19,10 @@ vi.mock("../commands/lint", () => ({
|
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
21
|
const componentGraphMock = vi.fn();
|
|
22
|
+
const generatePipelineMock = vi.fn();
|
|
22
23
|
vi.mock("../../components/cli-support", () => ({
|
|
23
24
|
computeComponentGraph: () => componentGraphMock(),
|
|
25
|
+
generateComponentsPipeline: (...a: unknown[]) => generatePipelineMock(...a),
|
|
24
26
|
}));
|
|
25
27
|
|
|
26
28
|
// Avoid running a real layout engine in tests; the format dispatch + size/engine
|
|
@@ -52,9 +54,14 @@ const discoverComponentsMock = vi.fn();
|
|
|
52
54
|
vi.mock("../../components/discover", () => ({
|
|
53
55
|
discoverComponents: (...a: unknown[]) => discoverComponentsMock(...a),
|
|
54
56
|
}));
|
|
55
|
-
vi.
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const loadChantConfigMock = vi.fn();
|
|
58
|
+
vi.mock("../../config", async () => {
|
|
59
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
60
|
+
return {
|
|
61
|
+
...actual,
|
|
62
|
+
loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
58
65
|
vi.mock("../../build", () => ({
|
|
59
66
|
build: () => Promise.resolve({ errors: [] }),
|
|
60
67
|
partitionByLexicon: () => ({}),
|
|
@@ -101,12 +108,15 @@ describe("runGraph", () => {
|
|
|
101
108
|
lintMock.mockReset();
|
|
102
109
|
layoutMock.mockReset();
|
|
103
110
|
componentGraphMock.mockReset();
|
|
111
|
+
generatePipelineMock.mockReset();
|
|
104
112
|
discoverComponentsMock.mockReset();
|
|
105
113
|
// Default: no components — the single-stack --live path most tests exercise.
|
|
106
114
|
discoverComponentsMock.mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
|
|
107
115
|
observeMock.mockReset();
|
|
108
116
|
loadPluginsMock.mockReset();
|
|
109
117
|
resolveLexMock.mockReset();
|
|
118
|
+
loadChantConfigMock.mockReset();
|
|
119
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
110
120
|
});
|
|
111
121
|
|
|
112
122
|
describe("Op graph (default)", () => {
|
|
@@ -318,6 +328,89 @@ describe("runGraph", () => {
|
|
|
318
328
|
expect(exit).toBe(1);
|
|
319
329
|
expect(stderrBuf.join("\n")).toContain("cycle: a ↔ b");
|
|
320
330
|
});
|
|
331
|
+
|
|
332
|
+
describe("CI/pipeline projection (--projection, #989)", () => {
|
|
333
|
+
test("rejects --projection without --components --format ir", async () => {
|
|
334
|
+
const exit = await runGraph({ args: makeArgs({ projection: "gitlab" }), plugins: [], serializers: [] });
|
|
335
|
+
expect(exit).toBe(1);
|
|
336
|
+
expect(stderrBuf.join("\n")).toMatch(/--projection needs --components --format ir/);
|
|
337
|
+
expect(componentGraphMock).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("rejects --projection with --components --format mermaid", async () => {
|
|
341
|
+
const exit = await runGraph({
|
|
342
|
+
args: makeArgs({ projection: "gitlab", components: true, format: "mermaid" }),
|
|
343
|
+
plugins: [],
|
|
344
|
+
serializers: [],
|
|
345
|
+
});
|
|
346
|
+
expect(exit).toBe(1);
|
|
347
|
+
expect(stderrBuf.join("\n")).toMatch(/--projection needs --components --format ir/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("--components --format ir --projection gitlab adds ir.pipeline, reusing generateComponentsPipeline", async () => {
|
|
351
|
+
lintMock.mockResolvedValue({ success: true });
|
|
352
|
+
componentGraphClean();
|
|
353
|
+
generatePipelineMock.mockResolvedValue({
|
|
354
|
+
success: true,
|
|
355
|
+
stages: ["wave-1", "wave-2", "wave-3"],
|
|
356
|
+
jobs: [
|
|
357
|
+
{ jobName: "shared-foundation", component: "shared-foundation", stage: "wave-1", needs: [] },
|
|
358
|
+
{ jobName: "loom-db", component: "loom-db", stage: "wave-2", needs: ["shared-foundation"] },
|
|
359
|
+
{ jobName: "loom-backend", component: "loom-backend", stage: "wave-3", needs: ["loom-db"] },
|
|
360
|
+
],
|
|
361
|
+
yaml: "stages: [...]\n",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const exit = await runGraph({
|
|
365
|
+
args: makeArgs({ format: "ir", components: true, projection: "gitlab" }),
|
|
366
|
+
plugins: [],
|
|
367
|
+
serializers: [],
|
|
368
|
+
});
|
|
369
|
+
expect(exit).toBe(0);
|
|
370
|
+
|
|
371
|
+
// Reuses the same generator `build --components --generate` calls —
|
|
372
|
+
// never re-derives stages/jobs/needs itself.
|
|
373
|
+
expect(generatePipelineMock).toHaveBeenCalledWith(expect.any(String), "gitlab", undefined, undefined);
|
|
374
|
+
|
|
375
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
376
|
+
// The component graph itself is untouched by the projection.
|
|
377
|
+
expect(ir.nodes.map((n: { id: string }) => n.id)).toEqual(["shared-foundation", "loom-db", "loom-backend"]);
|
|
378
|
+
expect(ir.groups.byWave["wave-2"]).toEqual(["loom-db"]);
|
|
379
|
+
|
|
380
|
+
// The CI/pipeline projection sits alongside it as first-class IR nodes/edges.
|
|
381
|
+
expect(ir.pipeline.provider).toBe("gitlab");
|
|
382
|
+
expect(ir.pipeline.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
|
|
383
|
+
expect(ir.pipeline.nodes).toEqual([
|
|
384
|
+
{ id: "shared-foundation", kind: "CIJob", component: "shared-foundation", stage: "wave-1" },
|
|
385
|
+
{ id: "loom-db", kind: "CIJob", component: "loom-db", stage: "wave-2" },
|
|
386
|
+
{ id: "loom-backend", kind: "CIJob", component: "loom-backend", stage: "wave-3" },
|
|
387
|
+
]);
|
|
388
|
+
// `needs:` edges, consumer job → producer job (mirrors the component
|
|
389
|
+
// graph's consumer → producer convention).
|
|
390
|
+
expect(ir.pipeline.edges).toEqual([
|
|
391
|
+
{ from: "loom-db", to: "shared-foundation", kind: "needs" },
|
|
392
|
+
{ from: "loom-backend", to: "loom-db", kind: "needs" },
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("an unsupported --projection lexicon fails the whole graph command", async () => {
|
|
397
|
+
lintMock.mockResolvedValue({ success: true });
|
|
398
|
+
componentGraphClean();
|
|
399
|
+
generatePipelineMock.mockResolvedValue({
|
|
400
|
+
success: false,
|
|
401
|
+
error: 'Lexicon "bogus" does not support generate mode (no generateComponentPipeline).',
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const exit = await runGraph({
|
|
405
|
+
args: makeArgs({ format: "ir", components: true, projection: "bogus" }),
|
|
406
|
+
plugins: [],
|
|
407
|
+
serializers: [],
|
|
408
|
+
});
|
|
409
|
+
expect(exit).toBe(1);
|
|
410
|
+
expect(stderrBuf.join("\n")).toContain("does not support generate mode");
|
|
411
|
+
expect(stdoutBuf.join("\n")).toBe("");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
321
414
|
});
|
|
322
415
|
|
|
323
416
|
describe("live graph (--live)", () => {
|
|
@@ -332,6 +425,7 @@ describe("runGraph", () => {
|
|
|
332
425
|
observeMock.mockResolvedValue({
|
|
333
426
|
observations: [{ lexicon: "aws", resources: { "web-vpc": { type: "AWS::EC2::VPC", status: "OK" } } }],
|
|
334
427
|
errors: [],
|
|
428
|
+
warnings: [],
|
|
335
429
|
});
|
|
336
430
|
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
337
431
|
expect(exit).toBe(0);
|
|
@@ -349,7 +443,7 @@ describe("runGraph", () => {
|
|
|
349
443
|
loadPluginsMock.mockResolvedValue([
|
|
350
444
|
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
351
445
|
]);
|
|
352
|
-
observeMock.mockResolvedValue({ observations: [], errors: [] });
|
|
446
|
+
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
353
447
|
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
354
448
|
expect(exit).toBe(0);
|
|
355
449
|
expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
|
|
@@ -390,6 +484,7 @@ describe("runGraph", () => {
|
|
|
390
484
|
observeMock.mockResolvedValue({
|
|
391
485
|
observations: [{ lexicon: "aws", resources: { "loom-db": { type: "AWS::RDS::DBInstance", status: "OK" } } }],
|
|
392
486
|
errors: [],
|
|
487
|
+
warnings: [],
|
|
393
488
|
});
|
|
394
489
|
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "local" }), plugins: [], serializers: [] });
|
|
395
490
|
expect(exit).toBe(0);
|
|
@@ -409,7 +504,7 @@ describe("runGraph", () => {
|
|
|
409
504
|
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
410
505
|
]);
|
|
411
506
|
discoverComponentsMock.mockResolvedValue({ errors: [{ message: "bad component" }], sourceFiles: [], components: new Map() });
|
|
412
|
-
observeMock.mockResolvedValue({ observations: [], errors: [] });
|
|
507
|
+
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
413
508
|
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
414
509
|
expect(exit).toBe(0);
|
|
415
510
|
expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
|
|
@@ -418,5 +513,59 @@ describe("runGraph", () => {
|
|
|
418
513
|
});
|
|
419
514
|
expect(stderrBuf.join("\n")).toMatch(/component discovery failed/i);
|
|
420
515
|
});
|
|
516
|
+
|
|
517
|
+
// #1166 — an environment can declare its own endpoint (a local emulator
|
|
518
|
+
// like Floci), so `--live --env floci` observes the right target even when
|
|
519
|
+
// the ambient shell never exported AWS_ENDPOINT_URL.
|
|
520
|
+
describe("declared endpoint (#1166)", () => {
|
|
521
|
+
const prevEndpoint = process.env.AWS_ENDPOINT_URL;
|
|
522
|
+
|
|
523
|
+
afterEach(() => {
|
|
524
|
+
if (prevEndpoint === undefined) delete process.env.AWS_ENDPOINT_URL;
|
|
525
|
+
else process.env.AWS_ENDPOINT_URL = prevEndpoint;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("applies the declared endpoint to AWS_ENDPOINT_URL for the observe call, then restores it", async () => {
|
|
529
|
+
delete process.env.AWS_ENDPOINT_URL;
|
|
530
|
+
loadChantConfigMock.mockResolvedValue({
|
|
531
|
+
config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
|
|
532
|
+
});
|
|
533
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
534
|
+
loadPluginsMock.mockResolvedValue([
|
|
535
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
536
|
+
]);
|
|
537
|
+
let seenDuringObserve: string | undefined;
|
|
538
|
+
observeMock.mockImplementation(async () => {
|
|
539
|
+
seenDuringObserve = process.env.AWS_ENDPOINT_URL;
|
|
540
|
+
return { observations: [], errors: [], warnings: [] };
|
|
541
|
+
});
|
|
542
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
|
|
543
|
+
expect(exit).toBe(0);
|
|
544
|
+
expect(seenDuringObserve).toBe("http://localhost:4566");
|
|
545
|
+
expect(process.env.AWS_ENDPOINT_URL).toBeUndefined(); // restored after the read
|
|
546
|
+
expect(stderrBuf.join("\n")).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("ambient AWS_ENDPOINT_URL still wins when already set", async () => {
|
|
550
|
+
process.env.AWS_ENDPOINT_URL = "http://real-endpoint.example";
|
|
551
|
+
loadChantConfigMock.mockResolvedValue({
|
|
552
|
+
config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
|
|
553
|
+
});
|
|
554
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
555
|
+
loadPluginsMock.mockResolvedValue([
|
|
556
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
557
|
+
]);
|
|
558
|
+
let seenDuringObserve: string | undefined;
|
|
559
|
+
observeMock.mockImplementation(async () => {
|
|
560
|
+
seenDuringObserve = process.env.AWS_ENDPOINT_URL;
|
|
561
|
+
return { observations: [], errors: [], warnings: [] };
|
|
562
|
+
});
|
|
563
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
|
|
564
|
+
expect(exit).toBe(0);
|
|
565
|
+
expect(seenDuringObserve).toBe("http://real-endpoint.example"); // ambient wins
|
|
566
|
+
expect(process.env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // untouched
|
|
567
|
+
expect(stderrBuf.join("\n")).toMatch(/ambient AWS_ENDPOINT_URL already set/);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
421
570
|
});
|
|
422
571
|
});
|
|
@@ -2,10 +2,11 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
4
|
import { partitionByLexicon, computeStackGraph, build } from "../../build";
|
|
5
|
-
import { buildGraphIr, buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type GraphIR } from "../../graph-ir";
|
|
5
|
+
import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
|
|
6
6
|
import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
|
|
7
7
|
import { observeResources } from "../../lifecycle/observe";
|
|
8
|
-
import { loadChantConfig } from "../../config";
|
|
8
|
+
import { loadChantConfig, environmentNames } from "../../config";
|
|
9
|
+
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
9
10
|
import { applyDetail, type DetailLevel } from "../../graph-detail";
|
|
10
11
|
import { applyLens, parseLens } from "../../graph-lens";
|
|
11
12
|
import { toMermaid } from "../../graph-mermaid";
|
|
@@ -16,7 +17,7 @@ import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
|
16
17
|
import { readFileSync } from "node:fs";
|
|
17
18
|
import { formatError, formatWarning, formatBold } from "../format";
|
|
18
19
|
import type { CommandContext } from "../registry";
|
|
19
|
-
import { computeComponentGraph } from "../../components/cli-support";
|
|
20
|
+
import { computeComponentGraph, generateComponentsPipeline } from "../../components/cli-support";
|
|
20
21
|
import { discoverComponents } from "../../components/discover";
|
|
21
22
|
import { cfnDeployStacks } from "./components";
|
|
22
23
|
|
|
@@ -33,6 +34,18 @@ import { cfnDeployStacks } from "./components";
|
|
|
33
34
|
export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
34
35
|
const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
|
|
35
36
|
const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
|
|
37
|
+
// `--projection <lexicon>` (#989) only means anything for the component
|
|
38
|
+
// graph's IR — it adds the CI/pipeline shape to `GraphIR.pipeline`, a field
|
|
39
|
+
// the other view formats' emitters (mermaid/dot/layout) don't read, and the
|
|
40
|
+
// plain (non-`--format`) `--components` text/`--json` modes don't build an
|
|
41
|
+
// IR at all. Reject every other combination up front rather than silently
|
|
42
|
+
// ignoring the flag.
|
|
43
|
+
if (ctx.args.projection && !(ctx.args.components && ctx.args.format === "ir")) {
|
|
44
|
+
console.error(formatError({
|
|
45
|
+
message: "--projection needs --components --format ir — the CI/pipeline projection extends the component-graph IR, the only mode that carries it.",
|
|
46
|
+
}));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
36
49
|
// `--live` graphs the provisioned (observed) infrastructure, not the declared
|
|
37
50
|
// source (epic #776). It only makes sense as a view format; default to `ir`.
|
|
38
51
|
if (ctx.args.live) {
|
|
@@ -76,10 +89,11 @@ async function runGraphLive(
|
|
|
76
89
|
// lexicon), so `ctx.plugins` is empty. The live path needs the project's
|
|
77
90
|
// observation plugins — load them here, mirroring the lifecycle handlers.
|
|
78
91
|
const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
|
|
79
|
-
|
|
92
|
+
const declaredEnvNames = environmentNames(config.environments);
|
|
93
|
+
if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
|
|
80
94
|
console.error(formatError({
|
|
81
95
|
message: `Unknown environment "${environment}"`,
|
|
82
|
-
hint: `Defined environments: ${
|
|
96
|
+
hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
|
|
83
97
|
}));
|
|
84
98
|
return 1;
|
|
85
99
|
}
|
|
@@ -121,30 +135,58 @@ async function runGraphLive(
|
|
|
121
135
|
console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
139
|
+
// Floci), so this read is self-sufficient even when the ambient shell never
|
|
140
|
+
// exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
|
|
141
|
+
// Scoped to just this describe/enrich pass — restored in `finally` so it
|
|
142
|
+
// never leaks into a later invocation in the same process.
|
|
143
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
|
|
144
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
145
|
+
|
|
146
|
+
let ir: GraphIR;
|
|
147
|
+
let observations: LiveObservation[];
|
|
148
|
+
try {
|
|
149
|
+
const observeResult = await observeResources(environment, observing, buildResult, {
|
|
150
|
+
owned: true,
|
|
151
|
+
stacks: [...stacks],
|
|
152
|
+
});
|
|
153
|
+
observations = observeResult.observations;
|
|
154
|
+
const { errors, warnings } = observeResult;
|
|
155
|
+
for (const e of errors) console.error(formatWarning({ message: e }));
|
|
156
|
+
// Unobserved entities (#1089) arrive as warnings — a node missing from the
|
|
157
|
+
// live graph because nobody looked is a different fact from one that isn't
|
|
158
|
+
// deployed, and the diagram alone cannot say which. Capped: an estate with no
|
|
159
|
+
// ownership markers can produce one per declared entity, and a wall of them
|
|
160
|
+
// buries the graph output. The full list is `lifecycle diff --live`.
|
|
161
|
+
const WARN_CAP = 5;
|
|
162
|
+
for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
|
|
163
|
+
if (warnings.length > WARN_CAP) {
|
|
164
|
+
console.error(formatWarning({
|
|
165
|
+
message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
129
168
|
|
|
130
|
-
|
|
169
|
+
ir = buildLiveGraphIr(observations);
|
|
131
170
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
171
|
+
// Enrich node attrs from the fuller live config (#784) so references are
|
|
172
|
+
// present for edge reconstruction — describeResources metadata alone is often
|
|
173
|
+
// too thin (e.g. AWS returns stack outputs, not per-resource references).
|
|
174
|
+
for (const p of observing) {
|
|
175
|
+
if (!p.enrichLiveAttrs) continue;
|
|
176
|
+
try {
|
|
177
|
+
const enriched = await p.enrichLiveAttrs({ environment, owned: true });
|
|
178
|
+
ir = {
|
|
179
|
+
...ir,
|
|
180
|
+
nodes: ir.nodes.map((n) =>
|
|
181
|
+
n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
|
|
186
|
+
}
|
|
147
187
|
}
|
|
188
|
+
} finally {
|
|
189
|
+
endpointResult.restore();
|
|
148
190
|
}
|
|
149
191
|
|
|
150
192
|
// Reconstruct edges + containment from live references (#778): merge the
|
|
@@ -166,10 +208,14 @@ async function runGraphLive(
|
|
|
166
208
|
const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
|
|
167
209
|
if (declared.errors.length === 0) {
|
|
168
210
|
const declaredIr = buildGraphIr(declared.entities, projectPath);
|
|
211
|
+
// Declared nodes chant could not read are painted `neutral`, not
|
|
212
|
+
// `accent`/pending (#1089) — a wrong-cluster or unsupported-kind read
|
|
213
|
+
// must not draw the estate as "not deployed yet".
|
|
214
|
+
const overlayOpts = { unobserved: collectUnobserved(observations) };
|
|
169
215
|
ir =
|
|
170
216
|
args.overlayAnchor === "live"
|
|
171
|
-
? overlayGraphs(ir, declaredIr)
|
|
172
|
-
: sourceOverlayGraphs(declaredIr, ir);
|
|
217
|
+
? overlayGraphs(ir, declaredIr, overlayOpts)
|
|
218
|
+
: sourceOverlayGraphs(declaredIr, ir, overlayOpts);
|
|
173
219
|
} else {
|
|
174
220
|
console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
|
|
175
221
|
}
|
|
@@ -247,6 +293,15 @@ async function runComponentGraph(ctx: CommandContext): Promise<number> {
|
|
|
247
293
|
* the CI pipeline. Distinct from `runGraphView`, which emits the AWS *entity*
|
|
248
294
|
* graph — the component projection has one node per component, not per resource.
|
|
249
295
|
*
|
|
296
|
+
* `--projection <lexicon>` (#989, `--format ir` only — validated in `runGraph`)
|
|
297
|
+
* adds the **CI/pipeline projection** alongside this component graph:
|
|
298
|
+
* `ir.pipeline` carries the stages/jobs/`needs` `<lexicon>`'s
|
|
299
|
+
* `generateComponentPipeline` synthesizes for `chant build --components
|
|
300
|
+
* --generate <lexicon>` (`generateComponentsPipeline`, ../../components/cli-support.ts)
|
|
301
|
+
* — reused wholesale, not re-derived, so a consumer (e.g. behold, epic #492/
|
|
302
|
+
* INTENTIUS/behold#54) gets the pipeline shape as first-class IR nodes/edges
|
|
303
|
+
* instead of re-deriving it from `dependsOn` or parsing generated CI YAML.
|
|
304
|
+
*
|
|
250
305
|
* Lint-gated like the entity view: the DAG stands for deployable source, so we
|
|
251
306
|
* refuse to emit it for source that does not pass lint.
|
|
252
307
|
*/
|
|
@@ -278,7 +333,7 @@ async function runComponentGraphView(
|
|
|
278
333
|
const waveOf = new Map<string, number>();
|
|
279
334
|
graph.waves.forEach((wave, i) => wave.forEach((name) => waveOf.set(name, i + 1)));
|
|
280
335
|
|
|
281
|
-
|
|
336
|
+
let ir: GraphIR = {
|
|
282
337
|
nodes: graph.order.map((name) => ({
|
|
283
338
|
id: name,
|
|
284
339
|
kind: "Component",
|
|
@@ -293,9 +348,49 @@ async function runComponentGraphView(
|
|
|
293
348
|
},
|
|
294
349
|
};
|
|
295
350
|
|
|
351
|
+
if (ctx.args.projection) {
|
|
352
|
+
const pipeline = await buildPipelineProjection(projectPath, ctx.args.projection, ctx.args.sandbox);
|
|
353
|
+
if (!pipeline.success) {
|
|
354
|
+
console.error(formatError({ message: pipeline.error ?? `Failed to generate ${ctx.args.projection} pipeline projection` }));
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
ir = { ...ir, pipeline: pipeline.pipeline };
|
|
358
|
+
}
|
|
359
|
+
|
|
296
360
|
return emitIr(ir, ctx, format);
|
|
297
361
|
}
|
|
298
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Reshape `generateComponentsPipeline`'s result (../../components/cli-support.ts
|
|
365
|
+
* — the exact function `chant build --components --generate <lexicon>` calls)
|
|
366
|
+
* into the IR's `IRPipeline` vocabulary (#989): one `IRPipelineNode` per
|
|
367
|
+
* generated CI job, one `IRPipelineEdge` per `needs:` dependency (consumer job
|
|
368
|
+
* → producer job, mirroring the component edges' consumer → producer
|
|
369
|
+
* direction). Every shape decision — job naming, one stage per wave,
|
|
370
|
+
* dependency resolution — stays owned by `lexicon`'s `generateComponentPipeline`;
|
|
371
|
+
* this only relabels its `{ stages, jobs }` output as IR nodes/edges, it never
|
|
372
|
+
* re-derives the graph.
|
|
373
|
+
*/
|
|
374
|
+
async function buildPipelineProjection(
|
|
375
|
+
projectPath: string,
|
|
376
|
+
lexicon: string,
|
|
377
|
+
sandbox?: boolean,
|
|
378
|
+
): Promise<{ success: true; pipeline: IRPipeline } | { success: false; error?: string }> {
|
|
379
|
+
const result = await generateComponentsPipeline(projectPath, lexicon, undefined, sandbox);
|
|
380
|
+
if (!result.success) return { success: false, error: result.error };
|
|
381
|
+
|
|
382
|
+
const jobs = result.jobs ?? [];
|
|
383
|
+
return {
|
|
384
|
+
success: true,
|
|
385
|
+
pipeline: {
|
|
386
|
+
provider: lexicon,
|
|
387
|
+
stages: result.stages ?? [],
|
|
388
|
+
nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
|
|
389
|
+
edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
299
394
|
/**
|
|
300
395
|
* `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
|
|
301
396
|
* `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
|