@intentius/chant 0.52.2 → 0.53.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/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- package/dist/cli/handlers/operator.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/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -66
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +30 -35
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.ts +9 -5
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.ts +28 -24
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/handlers/carve-emit.ts +8 -3
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +11 -13
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +38 -10
- package/src/cli/registry.ts +9 -1
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +53 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +19 -167
- package/src/terraform/aws-resources.test.ts +38 -22
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +45 -107
- package/src/terraform/types.ts +6 -0
|
@@ -3,17 +3,18 @@ import { formatError } from "../format";
|
|
|
3
3
|
import type { CommandContext } from "../registry";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* `chant carve advise --from <terraform-dir> [--json] [--report <path>]`
|
|
6
|
+
* `chant carve advise --from <terraform-dir | cdk.out> [--json] [--report <path>]`
|
|
7
7
|
*
|
|
8
|
-
* Read-only. Reuses `--from` (shared with migrate/import) for the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Read-only. Reuses `--from` (shared with migrate/import) for the source dir,
|
|
9
|
+
* and works out which source it is by looking at the directory (#1056) rather
|
|
10
|
+
* than adding a second flag. Loads no chant project and no lexicon plugins —
|
|
11
|
+
* pure analysis of a foreign tree.
|
|
11
12
|
*/
|
|
12
13
|
export async function runCarveAdvise(ctx: CommandContext): Promise<number> {
|
|
13
14
|
const { args } = ctx;
|
|
14
15
|
|
|
15
16
|
const result = await carveAdvise({
|
|
16
|
-
from: args.migrateFrom, // `--from <terraform-dir>`
|
|
17
|
+
from: args.migrateFrom, // `--from <terraform-dir | cdk.out>`
|
|
17
18
|
statePath: args.statePath, // `--state <tfstate>`
|
|
18
19
|
reportFile: args.reportFile, // `--report <path>`
|
|
19
20
|
});
|
|
@@ -21,7 +22,7 @@ export async function runCarveAdvise(ctx: CommandContext): Promise<number> {
|
|
|
21
22
|
if (!result.ok) {
|
|
22
23
|
console.error(formatError({
|
|
23
24
|
message: result.error ?? "carve advise failed",
|
|
24
|
-
hint: "chant carve advise --from ./path/to/terraform",
|
|
25
|
+
hint: "chant carve advise --from ./path/to/terraform (or ./cdk.out)",
|
|
25
26
|
}));
|
|
26
27
|
return 1;
|
|
27
28
|
}
|
|
@@ -38,7 +39,7 @@ export async function runCarveAdvise(ctx: CommandContext): Promise<number> {
|
|
|
38
39
|
export async function runCarveUnknown(_ctx: CommandContext): Promise<number> {
|
|
39
40
|
console.error(formatError({
|
|
40
41
|
message: "Unknown carve subcommand",
|
|
41
|
-
hint: "chant carve advise --from <terraform-dir> (read-only peelability report)",
|
|
42
|
+
hint: "chant carve advise --from <terraform-dir | cdk.out> (read-only peelability report)",
|
|
42
43
|
}));
|
|
43
44
|
return 1;
|
|
44
45
|
}
|
|
@@ -53,7 +53,7 @@ vi.mock("../../config", async () => {
|
|
|
53
53
|
};
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } = await import("./lifecycle");
|
|
56
|
+
const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } = await import("./lifecycle");
|
|
57
57
|
|
|
58
58
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
59
59
|
return {
|
|
@@ -1673,3 +1673,189 @@ describe("runLifecycleTeardown (#1222 — plan only)", () => {
|
|
|
1673
1673
|
expect(parsed.entries).toHaveLength(1);
|
|
1674
1674
|
});
|
|
1675
1675
|
});
|
|
1676
|
+
|
|
1677
|
+
describe("runLifecycleWhoami (#1982)", () => {
|
|
1678
|
+
let stdoutBuf: string[];
|
|
1679
|
+
let stderrBuf: string[];
|
|
1680
|
+
|
|
1681
|
+
beforeEach(() => {
|
|
1682
|
+
stdoutBuf = [];
|
|
1683
|
+
stderrBuf = [];
|
|
1684
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
1685
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
1686
|
+
loadChantConfigMock.mockReset();
|
|
1687
|
+
loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"] } });
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
afterEach(() => {
|
|
1691
|
+
vi.restoreAllMocks();
|
|
1692
|
+
delete process.env.AWS_ENDPOINT_URL;
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
function whoamiCtx(plugins: LexiconPlugin[], overrides: Partial<ParsedArgs> = {}) {
|
|
1696
|
+
return {
|
|
1697
|
+
args: makeArgs({ command: "lifecycle", path: "whoami", extraPositional: "prod", ...overrides }),
|
|
1698
|
+
plugins,
|
|
1699
|
+
serializers: plugins.map((p) => p.serializer),
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const awsIdentity = createMockPlugin({
|
|
1704
|
+
name: "aws",
|
|
1705
|
+
describeIdentity: async () => ({
|
|
1706
|
+
identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
|
|
1707
|
+
scope: "491500000000 us-east-1",
|
|
1708
|
+
source: "env AWS_ACCESS_KEY_ID",
|
|
1709
|
+
endpoint: "https://sts.us-east-1.amazonaws.com/",
|
|
1710
|
+
}),
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
test("missing environment arg → exit 1", async () => {
|
|
1714
|
+
const exit = await runLifecycleWhoami(whoamiCtx([], { extraPositional: undefined }));
|
|
1715
|
+
expect(exit).toBe(1);
|
|
1716
|
+
expect(stderrBuf.join("\n")).toContain("Environment is required");
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
test("environment not in config → exit 1", async () => {
|
|
1720
|
+
const exit = await runLifecycleWhoami(whoamiCtx([], { extraPositional: "nope" }));
|
|
1721
|
+
expect(exit).toBe(1);
|
|
1722
|
+
expect(stderrBuf.join("\n")).toContain('Unknown environment "nope"');
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
test("one row per configured lexicon, and --json emits the same rows", async () => {
|
|
1726
|
+
const plugins: LexiconPlugin[] = [
|
|
1727
|
+
awsIdentity,
|
|
1728
|
+
createMockPlugin({
|
|
1729
|
+
name: "k8s",
|
|
1730
|
+
describeIdentity: async () => ({ unresolved: { reason: "no-binding", detail: "no context" } }),
|
|
1731
|
+
}),
|
|
1732
|
+
createMockPlugin({ name: "github" }),
|
|
1733
|
+
];
|
|
1734
|
+
|
|
1735
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins))).toBe(0);
|
|
1736
|
+
const table = stdoutBuf.join("\n");
|
|
1737
|
+
expect(table).toContain("arn:aws:sts::491500000000:assumed-role/deploy/ci");
|
|
1738
|
+
expect(table).toContain("k8s");
|
|
1739
|
+
expect(table).toContain("github");
|
|
1740
|
+
|
|
1741
|
+
stdoutBuf.length = 0;
|
|
1742
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins, { json: true }))).toBe(0);
|
|
1743
|
+
const parsed = JSON.parse(stdoutBuf.join("\n")) as {
|
|
1744
|
+
environment: string;
|
|
1745
|
+
identities: Array<{ lexicon: string; status: string; identity?: string; reason?: string }>;
|
|
1746
|
+
};
|
|
1747
|
+
expect(parsed.environment).toBe("prod");
|
|
1748
|
+
expect(parsed.identities.map((r) => [r.lexicon, r.status])).toEqual([
|
|
1749
|
+
["aws", "reported"],
|
|
1750
|
+
["k8s", "unresolved"],
|
|
1751
|
+
["github", "not-reported"],
|
|
1752
|
+
]);
|
|
1753
|
+
expect(parsed.identities[1].reason).toBe("no-binding");
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
test("a lexicon that implements nothing reads as 'not reported', never an empty identity", async () => {
|
|
1757
|
+
const exit = await runLifecycleWhoami(whoamiCtx([createMockPlugin({ name: "helm" })]));
|
|
1758
|
+
expect(exit).toBe(0);
|
|
1759
|
+
expect(stdoutBuf.join("\n")).toContain("not reported");
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
test("an unresolved identity exits 0 by default and nonzero only under --strict", async () => {
|
|
1763
|
+
const plugins: LexiconPlugin[] = [
|
|
1764
|
+
createMockPlugin({
|
|
1765
|
+
name: "aws",
|
|
1766
|
+
describeIdentity: async () => ({ unresolved: { reason: "no-credentials" } }),
|
|
1767
|
+
}),
|
|
1768
|
+
];
|
|
1769
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins))).toBe(0);
|
|
1770
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins, { strict: true }))).toBe(1);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
test("--strict does not fail on a lexicon that simply does not implement the capability", async () => {
|
|
1774
|
+
const exit = await runLifecycleWhoami(whoamiCtx([createMockPlugin({ name: "github" })], { strict: true }));
|
|
1775
|
+
expect(exit).toBe(0);
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
test("the positional lexicon filter narrows the report", async () => {
|
|
1779
|
+
const plugins: LexiconPlugin[] = [awsIdentity, createMockPlugin({ name: "k8s" })];
|
|
1780
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins, { extraPositional2: "aws", json: true }))).toBe(0);
|
|
1781
|
+
const parsed = JSON.parse(stdoutBuf.join("\n")) as { identities: Array<{ lexicon: string }> };
|
|
1782
|
+
expect(parsed.identities.map((r) => r.lexicon)).toEqual(["aws"]);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
test("an unconfigured lexicon filter → exit 1", async () => {
|
|
1786
|
+
const exit = await runLifecycleWhoami(whoamiCtx([awsIdentity], { extraPositional2: "gcp" }));
|
|
1787
|
+
expect(exit).toBe(1);
|
|
1788
|
+
expect(stderrBuf.join("\n")).toContain('Lexicon "gcp" is not configured');
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
test("the environment's declared endpoint is applied for the identity query, exactly as for a live read", async () => {
|
|
1792
|
+
// #1166 parity: without this, whoami reports the identity of real AWS
|
|
1793
|
+
// while `describeResources` reads the emulator — the wrong-target answer
|
|
1794
|
+
// this command exists to surface.
|
|
1795
|
+
loadChantConfigMock.mockResolvedValue({
|
|
1796
|
+
config: { environments: [{ name: "prod", endpoint: "http://localhost:4566" }] },
|
|
1797
|
+
});
|
|
1798
|
+
let seenEndpoint: string | undefined;
|
|
1799
|
+
const plugins: LexiconPlugin[] = [
|
|
1800
|
+
createMockPlugin({
|
|
1801
|
+
name: "aws",
|
|
1802
|
+
emulator: awsEmulatorStub,
|
|
1803
|
+
describeIdentity: async () => {
|
|
1804
|
+
seenEndpoint = process.env.AWS_ENDPOINT_URL;
|
|
1805
|
+
return { identity: "arn:x", scope: "s", source: "env" };
|
|
1806
|
+
},
|
|
1807
|
+
}),
|
|
1808
|
+
];
|
|
1809
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins))).toBe(0);
|
|
1810
|
+
expect(seenEndpoint).toBe("http://localhost:4566");
|
|
1811
|
+
// Restored after the call, so it never leaks into a later invocation.
|
|
1812
|
+
expect(process.env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
test("a single declared stack region is handed to the lexicon; disagreeing regions are not guessed", async () => {
|
|
1816
|
+
const seen: Array<string | undefined> = [];
|
|
1817
|
+
const plugin = createMockPlugin({
|
|
1818
|
+
name: "aws",
|
|
1819
|
+
describeIdentity: async (options) => {
|
|
1820
|
+
seen.push(options.region);
|
|
1821
|
+
return { identity: "arn:x", scope: "s", source: "env" };
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
loadChantConfigMock.mockResolvedValue({
|
|
1825
|
+
config: { environments: ["prod"], stacks: [{ name: "a", src: "src/a", region: "eu-west-1" }] },
|
|
1826
|
+
});
|
|
1827
|
+
await runLifecycleWhoami(whoamiCtx([plugin]));
|
|
1828
|
+
loadChantConfigMock.mockResolvedValue({
|
|
1829
|
+
config: {
|
|
1830
|
+
environments: ["prod"],
|
|
1831
|
+
stacks: [
|
|
1832
|
+
{ name: "a", src: "src/a", region: "eu-west-1" },
|
|
1833
|
+
{ name: "b", src: "src/b", region: "us-east-1" },
|
|
1834
|
+
],
|
|
1835
|
+
},
|
|
1836
|
+
});
|
|
1837
|
+
await runLifecycleWhoami(whoamiCtx([plugin]));
|
|
1838
|
+
expect(seen).toEqual(["eu-west-1", undefined]);
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
test("no credential value survives into the rendered rows", async () => {
|
|
1842
|
+
process.env.CHANT_TEST_API_TOKEN = "tok_averyrealtokenvalue";
|
|
1843
|
+
try {
|
|
1844
|
+
const plugins: LexiconPlugin[] = [
|
|
1845
|
+
createMockPlugin({
|
|
1846
|
+
name: "aws",
|
|
1847
|
+
describeIdentity: async () => ({
|
|
1848
|
+
identity: "arn:aws:iam::1:user/ci",
|
|
1849
|
+
scope: "us-east-1",
|
|
1850
|
+
source: "tok_averyrealtokenvalue",
|
|
1851
|
+
}),
|
|
1852
|
+
}),
|
|
1853
|
+
];
|
|
1854
|
+
expect(await runLifecycleWhoami(whoamiCtx(plugins, { json: true }))).toBe(0);
|
|
1855
|
+
expect(stdoutBuf.join("\n")).not.toContain("tok_averyrealtokenvalue");
|
|
1856
|
+
expect(stdoutBuf.join("\n")).toContain("[redacted]");
|
|
1857
|
+
} finally {
|
|
1858
|
+
delete process.env.CHANT_TEST_API_TOKEN;
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
});
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type NormalizedObservation,
|
|
31
31
|
type UnobservedEntity,
|
|
32
32
|
} from "../../observation";
|
|
33
|
+
import { describeIdentities, identityStatusText, type IdentityRow } from "../../identity";
|
|
33
34
|
import { discoverComponents } from "../../components/discover";
|
|
34
35
|
import { cfnDeployStacks } from "./components";
|
|
35
36
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
@@ -1583,13 +1584,136 @@ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
|
|
|
1583
1584
|
return 0;
|
|
1584
1585
|
}
|
|
1585
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* The region core hands `describeIdentity` — the one the environment's stacks
|
|
1589
|
+
* declare, when they declare exactly one (#1261's `stacks[].region`).
|
|
1590
|
+
*
|
|
1591
|
+
* Stacks that declare none, or that disagree, resolve to `undefined`, and the
|
|
1592
|
+
* lexicon then resolves the region its own read path would. Guessing one of
|
|
1593
|
+
* several would report a binding half the estate does not use, which is the
|
|
1594
|
+
* failure this command exists to prevent.
|
|
1595
|
+
*/
|
|
1596
|
+
function singleDeclaredRegion(targets: readonly StackTarget[]): string | undefined {
|
|
1597
|
+
const regions = new Set(targets.map((t) => t.region).filter((r): r is string => Boolean(r)));
|
|
1598
|
+
return regions.size === 1 ? [...regions][0] : undefined;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* chant lifecycle whoami <environment> [lexicon] [--json] [--strict]
|
|
1603
|
+
*
|
|
1604
|
+
* Read-only pre-flight (#1982): who chant would act as in each configured
|
|
1605
|
+
* lexicon, and what that principal is scoped to, before it acts. Never
|
|
1606
|
+
* mutates, never gates — `--strict` is the only way this exits non-zero on an
|
|
1607
|
+
* unresolved row, and a lexicon that answers for nothing is reported as such
|
|
1608
|
+
* rather than as an empty identity.
|
|
1609
|
+
*/
|
|
1610
|
+
export async function runLifecycleWhoami(ctx: CommandContext): Promise<number> {
|
|
1611
|
+
const { args, plugins } = ctx;
|
|
1612
|
+
const environment = args.extraPositional;
|
|
1613
|
+
const lexiconFilter = args.extraPositional2;
|
|
1614
|
+
|
|
1615
|
+
if (!environment) {
|
|
1616
|
+
console.error(formatError({
|
|
1617
|
+
message: "Environment is required: chant lifecycle whoami <environment> [lexicon] [--json] [--strict]",
|
|
1618
|
+
}));
|
|
1619
|
+
return 1;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const projectPath = resolve(".");
|
|
1623
|
+
const { config } = await loadChantConfig(projectPath);
|
|
1624
|
+
const declaredEnvNames = environmentNames(config.environments);
|
|
1625
|
+
if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
|
|
1626
|
+
console.error(formatError({
|
|
1627
|
+
message: `Unknown environment "${environment}"`,
|
|
1628
|
+
hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
|
|
1629
|
+
}));
|
|
1630
|
+
return 1;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const targetPlugins = lexiconFilter ? plugins.filter((p) => p.name === lexiconFilter) : plugins;
|
|
1634
|
+
if (targetPlugins.length === 0) {
|
|
1635
|
+
console.error(formatError({
|
|
1636
|
+
message: `Lexicon "${lexiconFilter}" is not configured for this project`,
|
|
1637
|
+
hint: `Configured: ${plugins.map((p) => p.name).join(", ") || "(none)"}`,
|
|
1638
|
+
}));
|
|
1639
|
+
return 1;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// The environment's declared endpoint applies here exactly as it does to a
|
|
1643
|
+
// `--live` read (#1166). Without it whoami would report the identity of real
|
|
1644
|
+
// AWS while `describeResources` reads the emulator — the wrong-target answer
|
|
1645
|
+
// this command exists to make visible.
|
|
1646
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, targetPlugins);
|
|
1647
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
1648
|
+
|
|
1649
|
+
const region = singleDeclaredRegion(resolveStackTargets(args, config));
|
|
1650
|
+
let rows: IdentityRow[];
|
|
1651
|
+
try {
|
|
1652
|
+
rows = await describeIdentities(targetPlugins, {
|
|
1653
|
+
environment,
|
|
1654
|
+
...(region ? { region } : {}),
|
|
1655
|
+
cwd: projectPath,
|
|
1656
|
+
});
|
|
1657
|
+
} finally {
|
|
1658
|
+
endpointResult.restore();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (args.json) {
|
|
1662
|
+
console.log(JSON.stringify({ environment, identities: rows }, null, 2));
|
|
1663
|
+
} else {
|
|
1664
|
+
printIdentityTable(rows);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Never a gate by default: an unresolved identity is a report, and a
|
|
1668
|
+
// pre-flight command that fails the build has stopped being pre-flight.
|
|
1669
|
+
const unresolved = rows.filter((r) => r.status === "unresolved");
|
|
1670
|
+
if (args.strict && unresolved.length > 0) {
|
|
1671
|
+
console.error(formatError({
|
|
1672
|
+
message: `--strict: ${unresolved.length} lexicon(s) could not resolve an identity: ${unresolved.map((r) => r.lexicon).join(", ")}`,
|
|
1673
|
+
}));
|
|
1674
|
+
return 1;
|
|
1675
|
+
}
|
|
1676
|
+
return 0;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function printIdentityTable(rows: IdentityRow[]): void {
|
|
1680
|
+
if (rows.length === 0) {
|
|
1681
|
+
console.error(formatWarning({ message: "No lexicons are configured for this project" }));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const width = (pick: (r: IdentityRow) => string): number =>
|
|
1685
|
+
Math.max(...rows.map((r) => pick(r).length));
|
|
1686
|
+
const lexWidth = Math.max(width((r) => r.lexicon), "LEXICON".length);
|
|
1687
|
+
const idWidth = Math.max(width((r) => r.identity ?? identityStatusText(r)), "IDENTITY".length);
|
|
1688
|
+
const scopeWidth = Math.max(width((r) => r.scope ?? ""), "SCOPE".length);
|
|
1689
|
+
|
|
1690
|
+
console.log(
|
|
1691
|
+
"LEXICON".padEnd(lexWidth + 2) + "IDENTITY".padEnd(idWidth + 2) + "SCOPE".padEnd(scopeWidth + 2) + "SOURCE",
|
|
1692
|
+
);
|
|
1693
|
+
for (const row of rows) {
|
|
1694
|
+
const identity = row.status === "reported" ? (row.identity ?? "") : identityStatusText(row);
|
|
1695
|
+
console.log(
|
|
1696
|
+
row.lexicon.padEnd(lexWidth + 2) +
|
|
1697
|
+
identity.padEnd(idWidth + 2) +
|
|
1698
|
+
(row.scope ?? "").padEnd(scopeWidth + 2) +
|
|
1699
|
+
(row.source ?? ""),
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
// The resolved address and the reason's detail are both sentences, so they
|
|
1703
|
+
// go under the table rather than widening a column every row pays for.
|
|
1704
|
+
for (const row of rows) {
|
|
1705
|
+
if (row.endpoint) console.error(` ${row.lexicon}: resolved endpoint ${row.endpoint}`);
|
|
1706
|
+
if (row.detail) console.error(` ${row.lexicon}: ${row.detail}`);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1586
1710
|
/**
|
|
1587
1711
|
* Fallback for unknown state subcommands.
|
|
1588
1712
|
*/
|
|
1589
1713
|
export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
|
|
1590
1714
|
console.error(formatError({
|
|
1591
1715
|
message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
|
|
1592
|
-
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle teardown, chant lifecycle log",
|
|
1716
|
+
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle whoami, chant lifecycle teardown, chant lifecycle log",
|
|
1593
1717
|
}));
|
|
1594
1718
|
return 1;
|
|
1595
1719
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
2
|
+
import { extractStepRecords, extractIndexedStepRecords, stepRecordEmitter, countActivities, queryGateState } from "./op-progress";
|
|
3
|
+
import type { StepRecord } from "../../op/local-executor";
|
|
3
4
|
import type { OpConfig } from "../../op/types";
|
|
4
5
|
import type { EffectReceiptRef } from "../../op/receipt-store";
|
|
5
6
|
import type { WorkflowHistoryRaw, HistoryEvent } from "./run-client";
|
|
@@ -171,6 +172,71 @@ describe("extractStepRecords", () => {
|
|
|
171
172
|
});
|
|
172
173
|
});
|
|
173
174
|
|
|
175
|
+
describe("stepRecordEmitter", () => {
|
|
176
|
+
// chant #2032. Declared order is main phases then `onFailure` reversed, so an
|
|
177
|
+
// unreached main phase sits *before* an `onFailure` phase that ran. The live
|
|
178
|
+
// poll therefore returns [Main(fail), Comp(ok)] and the terminal `final: true`
|
|
179
|
+
// pass returns [Main(fail), Never(skipped), Comp(ok)] — an insertion, not an
|
|
180
|
+
// append. The old emitted-count watermark sliced at 2 and re-shipped Comp
|
|
181
|
+
// while dropping Never entirely. The #1676 coverage above misses this because
|
|
182
|
+
// in those configs the skipped record lands last, where insert and append are
|
|
183
|
+
// the same operation.
|
|
184
|
+
const config: OpConfig = {
|
|
185
|
+
name: "op", overview: "o",
|
|
186
|
+
phases: [
|
|
187
|
+
{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] },
|
|
188
|
+
{ name: "Never", steps: [{ kind: "activity", fn: "never" }] },
|
|
189
|
+
],
|
|
190
|
+
onFailure: [{ name: "Comp", steps: [{ kind: "activity", fn: "comp" }] }],
|
|
191
|
+
};
|
|
192
|
+
// Main fails, Never is never reached, compensation runs and succeeds.
|
|
193
|
+
const history: WorkflowHistoryRaw = {
|
|
194
|
+
events: [
|
|
195
|
+
scheduled("1", "boom", sec(0)),
|
|
196
|
+
failed("1", sec(1), "boom"),
|
|
197
|
+
scheduled("2", "comp", sec(2)),
|
|
198
|
+
completed("2", sec(3)),
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const MAIN: StepRecord = { phase: "Main", fn: "boom", status: "fail", durationMs: 1000, error: "boom" };
|
|
203
|
+
const NEVER: StepRecord = { phase: "Never", fn: "never", status: "skipped", durationMs: 0 };
|
|
204
|
+
const COMP: StepRecord = { phase: "Comp", fn: "comp", status: "ok", durationMs: 1000 };
|
|
205
|
+
|
|
206
|
+
test("the final pass inserts rather than appends, so list position is not an identity", () => {
|
|
207
|
+
expect(extractIndexedStepRecords(config, history)).toEqual([
|
|
208
|
+
{ index: 0, record: MAIN },
|
|
209
|
+
{ index: 2, record: COMP },
|
|
210
|
+
]);
|
|
211
|
+
expect(extractIndexedStepRecords(config, history, { final: true })).toEqual([
|
|
212
|
+
{ index: 0, record: MAIN },
|
|
213
|
+
{ index: 1, record: NEVER },
|
|
214
|
+
{ index: 2, record: COMP },
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("each record ships exactly once across the poll passes and the final pass", () => {
|
|
219
|
+
const emitted: StepRecord[] = [];
|
|
220
|
+
const emit = stepRecordEmitter((r) => emitted.push(r));
|
|
221
|
+
|
|
222
|
+
emit(extractIndexedStepRecords(config, history)); // a poll while the workflow closes
|
|
223
|
+
emit(extractIndexedStepRecords(config, history)); // an identical re-poll adds nothing
|
|
224
|
+
emit(extractIndexedStepRecords(config, history, { final: true }));
|
|
225
|
+
|
|
226
|
+
expect(emitted).toEqual([MAIN, COMP, NEVER]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("an empty first poll does not shift what a later pass ships", () => {
|
|
230
|
+
const emitted: StepRecord[] = [];
|
|
231
|
+
const emit = stepRecordEmitter((r) => emitted.push(r));
|
|
232
|
+
|
|
233
|
+
emit(extractIndexedStepRecords(config, { events: [] }));
|
|
234
|
+
expect(emitted).toEqual([]);
|
|
235
|
+
emit(extractIndexedStepRecords(config, history, { final: true }));
|
|
236
|
+
expect(emitted).toEqual([MAIN, NEVER, COMP]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
174
240
|
describe("countActivities", () => {
|
|
175
241
|
test("counts scheduled and completed events", () => {
|
|
176
242
|
const history: WorkflowHistoryRaw = {
|
|
@@ -113,34 +113,55 @@ function terminalOutcomes(events: HistoryEvent[]): Map<string, TerminalOutcome>
|
|
|
113
113
|
return out;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A step record paired with the position of the declared step it came from.
|
|
118
|
+
*
|
|
119
|
+
* `index` is an offset into `declaredStepOrder(config)`, which depends only
|
|
120
|
+
* on the Op config — not on the history — so it is the record's identity
|
|
121
|
+
* and is stable across polls. The record *list* is not: a mid-run poll omits
|
|
122
|
+
* every step that hasn't settled yet, and the terminal `final: true` pass
|
|
123
|
+
* inserts a `"skipped"` record at each such step's declared position, which
|
|
124
|
+
* shifts every later record's list offset (chant #2032). Anything tracking
|
|
125
|
+
* what it has already seen must key on `index`, never on how many records
|
|
126
|
+
* the previous call returned.
|
|
127
|
+
*/
|
|
128
|
+
export interface IndexedStepRecord {
|
|
129
|
+
index: number;
|
|
130
|
+
record: StepRecord;
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
/**
|
|
117
134
|
* Reconstruct per-step progress records from a workflow's history, in
|
|
118
|
-
* declared order
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
135
|
+
* declared order, each tagged with its declared-step `index`. Only settled
|
|
136
|
+
* steps (matched to a scheduled event that has since completed or failed)
|
|
137
|
+
* produce a record — a step that hasn't been scheduled yet simply isn't in
|
|
138
|
+
* the result. Pass `final: true` once the workflow has reached a terminal
|
|
122
139
|
* status to also emit a `"skipped"` record for every declared step that
|
|
123
140
|
* never got a matching scheduled event (an untaken effect branch, or an
|
|
124
141
|
* `onFailure` phase on a run that succeeded) — never before, or a
|
|
125
142
|
* still-running Op would show its not-yet-reached steps as skipped.
|
|
126
143
|
*/
|
|
127
|
-
export function
|
|
144
|
+
export function extractIndexedStepRecords(
|
|
128
145
|
config: OpConfig,
|
|
129
146
|
history: WorkflowHistoryRaw,
|
|
130
147
|
opts: { final?: boolean } = {},
|
|
131
|
-
):
|
|
148
|
+
): IndexedStepRecord[] {
|
|
132
149
|
const events = history.events ?? [];
|
|
133
150
|
const queues = scheduledQueues(events);
|
|
134
151
|
const terminals = terminalOutcomes(events);
|
|
135
152
|
const nextIndex = new Map<string, number>();
|
|
136
153
|
|
|
137
|
-
const records:
|
|
138
|
-
|
|
154
|
+
const records: IndexedStepRecord[] = [];
|
|
155
|
+
const declared = declaredStepOrder(config);
|
|
156
|
+
for (let index = 0; index < declared.length; index++) {
|
|
157
|
+
const step = declared[index];
|
|
139
158
|
const queue = queues.get(step.fn);
|
|
140
159
|
const idx = nextIndex.get(step.fn) ?? 0;
|
|
141
160
|
const entry = queue?.[idx];
|
|
142
161
|
if (!entry) {
|
|
143
|
-
if (opts.final)
|
|
162
|
+
if (opts.final) {
|
|
163
|
+
records.push({ index, record: { phase: step.phase, fn: step.fn, status: "skipped", durationMs: 0 } });
|
|
164
|
+
}
|
|
144
165
|
continue;
|
|
145
166
|
}
|
|
146
167
|
nextIndex.set(step.fn, idx + 1);
|
|
@@ -151,16 +172,51 @@ export function extractStepRecords(
|
|
|
151
172
|
const durationMs =
|
|
152
173
|
entry.scheduledAt && terminal.at ? terminal.at.getTime() - entry.scheduledAt.getTime() : 0;
|
|
153
174
|
records.push({
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
175
|
+
index,
|
|
176
|
+
record: {
|
|
177
|
+
phase: step.phase,
|
|
178
|
+
fn: step.fn,
|
|
179
|
+
status: terminal.status,
|
|
180
|
+
durationMs,
|
|
181
|
+
...(terminal.error ? { error: terminal.error } : {}),
|
|
182
|
+
},
|
|
159
183
|
});
|
|
160
184
|
}
|
|
161
185
|
return records;
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
/** `extractIndexedStepRecords` without the indices — the plain declared-order record list. */
|
|
189
|
+
export function extractStepRecords(
|
|
190
|
+
config: OpConfig,
|
|
191
|
+
history: WorkflowHistoryRaw,
|
|
192
|
+
opts: { final?: boolean } = {},
|
|
193
|
+
): StepRecord[] {
|
|
194
|
+
return extractIndexedStepRecords(config, history, opts).map((r) => r.record);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A sink that forwards each declared step's record exactly once, however many
|
|
199
|
+
* times the poll loop re-derives the list.
|
|
200
|
+
*
|
|
201
|
+
* `--progress-json` calls this per poll with the whole list so far, plus once
|
|
202
|
+
* more with the terminal `final: true` list. Deduplicating by declared-step
|
|
203
|
+
* `index` is what makes that safe: an emitted-count watermark assumed the list
|
|
204
|
+
* was append-only, and the `final` pass's inserted `"skipped"` records break
|
|
205
|
+
* that assumption — with an unreached phase declared before an `onFailure`
|
|
206
|
+
* phase that ran, the insertion shifted the tail, so the last record shipped
|
|
207
|
+
* twice and the skipped one never shipped at all (chant #2032).
|
|
208
|
+
*/
|
|
209
|
+
export function stepRecordEmitter(emit: (record: StepRecord) => void): (records: IndexedStepRecord[]) => void {
|
|
210
|
+
const seen = new Set<number>();
|
|
211
|
+
return (records) => {
|
|
212
|
+
for (const { index, record } of records) {
|
|
213
|
+
if (seen.has(index)) continue;
|
|
214
|
+
seen.add(index);
|
|
215
|
+
emit(record);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
/** `{completed, scheduled}` activity counts for the summary line `chant run status`/`op-status` show alongside the full record list. */
|
|
165
221
|
export function countActivities(history: WorkflowHistoryRaw): { completed: number; scheduled: number } {
|
|
166
222
|
const events = history.events ?? [];
|