@intentius/chant 0.52.2 → 0.53.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.
Files changed (103) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/identity.d.ts +196 -0
  23. package/dist/identity.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/lexicon.d.ts +41 -0
  27. package/dist/lexicon.d.ts.map +1 -1
  28. package/dist/terraform/adopt-state.d.ts +17 -66
  29. package/dist/terraform/adopt-state.d.ts.map +1 -1
  30. package/dist/terraform/aws-resources.d.ts +1 -1
  31. package/dist/terraform/carve-provider.d.ts +142 -0
  32. package/dist/terraform/carve-provider.d.ts.map +1 -0
  33. package/dist/terraform/carve.d.ts +36 -3
  34. package/dist/terraform/carve.d.ts.map +1 -1
  35. package/dist/terraform/emit-source.d.ts +25 -0
  36. package/dist/terraform/emit-source.d.ts.map +1 -0
  37. package/dist/terraform/graduate.d.ts +11 -1
  38. package/dist/terraform/graduate.d.ts.map +1 -1
  39. package/dist/terraform/providers/aws.d.ts +19 -0
  40. package/dist/terraform/providers/aws.d.ts.map +1 -0
  41. package/dist/terraform/providers/gcp.d.ts +41 -0
  42. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  43. package/dist/terraform/providers/index.d.ts +15 -0
  44. package/dist/terraform/providers/index.d.ts.map +1 -0
  45. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  46. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  47. package/dist/terraform/score.d.ts +70 -4
  48. package/dist/terraform/score.d.ts.map +1 -1
  49. package/dist/terraform/tier-map.d.ts +30 -35
  50. package/dist/terraform/tier-map.d.ts.map +1 -1
  51. package/dist/terraform/types.d.ts +6 -0
  52. package/dist/terraform/types.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  55. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  56. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  57. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  58. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  59. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  60. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  61. package/src/cdk/advise.test.ts +208 -0
  62. package/src/cdk/advise.ts +44 -0
  63. package/src/cdk/assembly.ts +133 -0
  64. package/src/cdk/graph.test.ts +206 -0
  65. package/src/cdk/graph.ts +525 -0
  66. package/src/cdk/tier-map.ts +71 -0
  67. package/src/cdk/types.ts +115 -0
  68. package/src/cli/commands/carve-apply.ts +20 -4
  69. package/src/cli/commands/carve-bridge.ts +9 -5
  70. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  71. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  72. package/src/cli/commands/carve-emit.ts +28 -24
  73. package/src/cli/commands/carve.ts +139 -36
  74. package/src/cli/handlers/carve-emit.ts +8 -3
  75. package/src/cli/handlers/carve.ts +8 -7
  76. package/src/cli/handlers/lifecycle.test.ts +187 -1
  77. package/src/cli/handlers/lifecycle.ts +125 -1
  78. package/src/cli/main.ts +12 -5
  79. package/src/identity.test.ts +199 -0
  80. package/src/identity.ts +346 -0
  81. package/src/index.ts +1 -0
  82. package/src/lexicon.ts +53 -0
  83. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  84. package/src/terraform/adopt-state.test.ts +131 -0
  85. package/src/terraform/adopt-state.ts +19 -167
  86. package/src/terraform/aws-resources.test.ts +38 -22
  87. package/src/terraform/aws-resources.ts +1 -1
  88. package/src/terraform/carve-provider.test.ts +155 -0
  89. package/src/terraform/carve-provider.ts +237 -0
  90. package/src/terraform/carve.test.ts +55 -1
  91. package/src/terraform/carve.ts +0 -0
  92. package/src/terraform/emit-source.ts +39 -0
  93. package/src/terraform/graduate.test.ts +37 -0
  94. package/src/terraform/graduate.ts +55 -7
  95. package/src/terraform/graph.ts +3 -3
  96. package/src/terraform/providers/aws.ts +169 -0
  97. package/src/terraform/providers/gcp.test.ts +228 -0
  98. package/src/terraform/providers/gcp.ts +329 -0
  99. package/src/terraform/providers/index.ts +21 -0
  100. package/src/terraform/providers/kubernetes.ts +224 -0
  101. package/src/terraform/score.ts +111 -25
  102. package/src/terraform/tier-map.ts +45 -107
  103. 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 estate dir.
9
- * Loads no chant project and no lexicon plugins — pure analysis of a foreign
10
- * Terraform tree.
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
  }
package/src/cli/main.ts CHANGED
@@ -23,7 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
23
23
  import { runCarveEmit } from "./handlers/carve-emit";
24
24
  import { runCarveBridge } from "./handlers/carve-bridge";
25
25
  import { runCarveApply } from "./handlers/carve-apply";
26
- import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
26
+ import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
27
27
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
28
28
  import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
29
29
  import { runGraph } from "./handlers/graph";
@@ -461,10 +461,12 @@ Commands:
461
461
  --all-projects)
462
462
  migrate <file> Translate a workflow between lexicons
463
463
  (default: --from github --to gitlab)
464
- carve advise Read-only Terraform peelability advisor: rank which
465
- --from <tf-dir> resources are cheap to carve into native chant
464
+ carve advise Read-only peelability advisor: rank which resources
465
+ --from <dir> are cheap to carve into native chant
466
466
  (--json, --report <path>). Emits nothing, changes nothing.
467
- Needs @cdktf/hcl2json (npm install -D @cdktf/hcl2json).
467
+ --from a Terraform dir needs @cdktf/hcl2json
468
+ (npm install -D @cdktf/hcl2json); --from a CDK cloud
469
+ assembly (cdk.out) needs nothing and ranks constructs.
468
470
  carve emit Adopt a selected TF resource into chant source + report
469
471
  --from <tf-dir> its boundary. --state <tfstate> adopts offline
470
472
  --select <addr> (recommended for TF-managed resources); --env
@@ -556,6 +558,9 @@ Lifecycle (alias: lc):
556
558
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
557
559
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
558
560
  --json: emit the ChangeSet as JSON
561
+ lifecycle whoami <env> Who chant would act as in each configured lexicon,
562
+ and what that principal is scoped to — read-only,
563
+ before anything acts (--json, --strict)
559
564
  lifecycle teardown <env> Plan what deleting the environment would remove —
560
565
  marker-scoped (this project's stack + env); --yes
561
566
  executes the plan (production-like names also need
@@ -646,7 +651,8 @@ Options:
646
651
  --from <name> Source lexicon for migrate (default: github)
647
652
  --to <name> Target lexicon for migrate (default: gitlab)
648
653
  --emit <fmt> Migration output format: yaml (default) or ts
649
- --strict Escalate needs-review/validation to errors (migrate)
654
+ --strict Escalate needs-review/validation to errors (migrate);
655
+ exit nonzero on an unresolved identity (lifecycle whoami)
650
656
  --validate Run external validator (glci/glab) after migrate
651
657
  --use-composites Rewrite to composite calls when patterns match (migrate)
652
658
  --components Target discovered Component declarations instead of
@@ -882,6 +888,7 @@ const registry: CommandDef[] = [
882
888
  { name: "lifecycle rollback", handler: runLifecycleRollback },
883
889
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
884
890
  { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
891
+ { name: "lifecycle whoami", requiresPlugins: true, handler: runLifecycleWhoami },
885
892
  { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
886
893
  { name: "lifecycle log", handler: runLifecycleLog },
887
894
 
@@ -0,0 +1,199 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ describeIdentities,
4
+ identityRowFor,
5
+ identityStatusText,
6
+ isUnresolvedIdentity,
7
+ redactCredentialMaterial,
8
+ redactIdentityRow,
9
+ REDACTED,
10
+ type IdentityPlugin,
11
+ } from "./identity";
12
+
13
+ describe("the identity tri-state (#1982)", () => {
14
+ test("a lexicon with no describeIdentity is not-reported, never an empty identity", async () => {
15
+ const rows = await describeIdentities([{ name: "github" }], { environment: "prod" }, {});
16
+ expect(rows).toEqual([{ lexicon: "github", status: "not-reported" }]);
17
+ expect(rows[0].identity).toBeUndefined();
18
+ expect(identityStatusText(rows[0])).toContain("not reported");
19
+ });
20
+
21
+ test("a resolved identity carries principal, scope, source and endpoint", async () => {
22
+ const plugin: IdentityPlugin = {
23
+ name: "aws",
24
+ describeIdentity: async () => ({
25
+ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
26
+ scope: "491500000000 us-east-1",
27
+ source: "env AWS_ACCESS_KEY_ID",
28
+ endpoint: "https://sts.us-east-1.amazonaws.com/",
29
+ }),
30
+ };
31
+ const [row] = await describeIdentities([plugin], { environment: "prod" }, {});
32
+ expect(row).toEqual({
33
+ lexicon: "aws",
34
+ status: "reported",
35
+ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
36
+ scope: "491500000000 us-east-1",
37
+ source: "env AWS_ACCESS_KEY_ID",
38
+ endpoint: "https://sts.us-east-1.amazonaws.com/",
39
+ });
40
+ });
41
+
42
+ test("no credentials and could-not-determine are distinguishable answers", async () => {
43
+ const rows = await describeIdentities(
44
+ [
45
+ { name: "aws", describeIdentity: async () => ({ unresolved: { reason: "no-credentials" } }) },
46
+ {
47
+ name: "k8s",
48
+ describeIdentity: async () => ({ unresolved: { reason: "read-failed", detail: "connection refused" } }),
49
+ },
50
+ ],
51
+ { environment: "prod" },
52
+ {},
53
+ );
54
+ expect(rows[0]).toEqual({ lexicon: "aws", status: "unresolved", reason: "no-credentials" });
55
+ expect(identityStatusText(rows[0])).toContain("no identity is configured");
56
+ expect(rows[1]).toEqual({
57
+ lexicon: "k8s",
58
+ status: "unresolved",
59
+ reason: "read-failed",
60
+ detail: "connection refused",
61
+ });
62
+ expect(identityStatusText(rows[1])).toContain("self-query failed");
63
+ // The point of the distinction: "nothing is configured" and "I could not
64
+ // find out" must never render as the same row.
65
+ expect(identityStatusText(rows[0])).not.toBe(identityStatusText(rows[1]));
66
+ });
67
+
68
+ test("an empty principal is refused rather than reported as an identity", () => {
69
+ const row = identityRowFor("gcp", { identity: " ", scope: "acme-prod", source: "ADC" }, {});
70
+ expect(row.status).toBe("unresolved");
71
+ expect(row.reason).toBe("read-failed");
72
+ expect(row.identity).toBeUndefined();
73
+ });
74
+
75
+ test("a lexicon that throws degrades to read-failed and never fails the run", async () => {
76
+ const rows = await describeIdentities(
77
+ [
78
+ {
79
+ name: "azure",
80
+ describeIdentity: async () => {
81
+ throw new Error("az CLI is not installed");
82
+ },
83
+ },
84
+ { name: "aws", describeIdentity: async () => ({ identity: "arn:x", scope: "s", source: "env" }) },
85
+ ],
86
+ { environment: "prod" },
87
+ {},
88
+ );
89
+ expect(rows[0]).toEqual({
90
+ lexicon: "azure",
91
+ status: "unresolved",
92
+ reason: "read-failed",
93
+ detail: "az CLI is not installed",
94
+ });
95
+ expect(rows[1].status).toBe("reported");
96
+ });
97
+
98
+ test("rows come back in the order the lexicons were configured", async () => {
99
+ const names = ["k8s", "aws", "gcp", "github"];
100
+ const rows = await describeIdentities(
101
+ names.map((name) => ({ name })),
102
+ { environment: "prod" },
103
+ {},
104
+ );
105
+ expect(rows.map((r) => r.lexicon)).toEqual(names);
106
+ });
107
+
108
+ test("the environment, region and cwd reach the lexicon unchanged", async () => {
109
+ const seen: unknown[] = [];
110
+ await describeIdentities(
111
+ [
112
+ {
113
+ name: "aws",
114
+ describeIdentity: async (options) => {
115
+ seen.push(options);
116
+ return { identity: "arn:x", scope: "s", source: "env" };
117
+ },
118
+ },
119
+ ],
120
+ { environment: "prod", region: "eu-west-1", cwd: "/repo" },
121
+ {},
122
+ );
123
+ expect(seen).toEqual([{ environment: "prod", region: "eu-west-1", cwd: "/repo" }]);
124
+ });
125
+
126
+ test("isUnresolvedIdentity discriminates the two return shapes", () => {
127
+ expect(isUnresolvedIdentity({ unresolved: { reason: "no-binding" } })).toBe(true);
128
+ expect(isUnresolvedIdentity({ identity: "a", scope: "b", source: "c" })).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("no credential reaches the output (#1982)", () => {
133
+ test("a value held in a credential-named env var is redacted wherever it appears", () => {
134
+ const env = { GITHUB_TOKEN: "ghp_averyrealtokenvalue", HOME: "/Users/x" };
135
+ expect(redactCredentialMaterial("authorized by ghp_averyrealtokenvalue", env)).toBe(
136
+ `authorized by ${REDACTED}`,
137
+ );
138
+ });
139
+
140
+ test("every free-text field of a row is scrubbed, not just the identity", () => {
141
+ const env = { AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMIsecretKEY" };
142
+ const row = redactIdentityRow(
143
+ {
144
+ lexicon: "aws",
145
+ status: "reported",
146
+ identity: "wJalrXUtnFEMIsecretKEY",
147
+ scope: "wJalrXUtnFEMIsecretKEY",
148
+ source: "signed with wJalrXUtnFEMIsecretKEY",
149
+ endpoint: "https://sts.amazonaws.com/?k=wJalrXUtnFEMIsecretKEY",
150
+ detail: "wJalrXUtnFEMIsecretKEY",
151
+ },
152
+ env,
153
+ );
154
+ for (const field of [row.identity, row.scope, row.source, row.endpoint, row.detail]) {
155
+ expect(field).not.toContain("wJalrXUtnFEMIsecretKEY");
156
+ expect(field).toContain(REDACTED);
157
+ }
158
+ });
159
+
160
+ test("a short env value is not treated as a credential", () => {
161
+ // Redacting "dev" out of every identity would be worse than the leak.
162
+ expect(redactCredentialMaterial("cluster dev-a", { AUTH_TOKEN: "dev" })).toBe("cluster dev-a");
163
+ });
164
+
165
+ test("literal credential shapes are redacted with no env var involved", () => {
166
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVPmB92K27u";
167
+ expect(redactCredentialMaterial(jwt, {})).toBe(REDACTED);
168
+ expect(redactCredentialMaterial("Bearer abcdefghijklmnopqrstuvwxyz012345", {})).toBe(REDACTED);
169
+ expect(
170
+ redactCredentialMaterial("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", {}),
171
+ ).toBe(REDACTED);
172
+ });
173
+
174
+ test("real principals survive redaction unchanged", () => {
175
+ // A mangled principal is a wrong answer, not a safe one. Nothing here is
176
+ // entropy-scored, so these pass through whole.
177
+ const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
178
+ for (const principal of [
179
+ "arn:aws:sts::491500000000:assumed-role/deploy/ci",
180
+ "system:serviceaccount:chant:deployer",
181
+ "deploy@acme.iam.gserviceaccount.com",
182
+ "https://prod-eks-a.eu-west-1.eks.amazonaws.com",
183
+ "491500000000 us-east-1",
184
+ ]) {
185
+ expect(redactCredentialMaterial(principal, env)).toBe(principal);
186
+ }
187
+ });
188
+
189
+ test("an identity built out of the session token is redacted, not printed", () => {
190
+ const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
191
+ const row = identityRowFor(
192
+ "aws",
193
+ { identity: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN", scope: "us-east-1", source: "env" },
194
+ env,
195
+ );
196
+ expect(row.identity).toBe(REDACTED);
197
+ expect(row.identity).not.toContain("FwoGZXIvYXdz");
198
+ });
199
+ });