@intentius/chant-lexicon-aws 0.124.52 → 0.124.54

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 (78) hide show
  1. package/dist/codegen/generate-lexicon.d.ts +2 -0
  2. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  3. package/dist/codegen/generate-lexicon.js +1 -0
  4. package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
  5. package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
  6. package/dist/components/__tests__/mock-cloud-executor.js +18 -1
  7. package/dist/components/apply.d.ts +44 -2
  8. package/dist/components/apply.d.ts.map +1 -1
  9. package/dist/components/apply.js +260 -29
  10. package/dist/components/cfn-adopt.d.ts +99 -0
  11. package/dist/components/cfn-adopt.d.ts.map +1 -0
  12. package/dist/components/cfn-adopt.js +278 -0
  13. package/dist/components/cfn-imports.d.ts +84 -0
  14. package/dist/components/cfn-imports.d.ts.map +1 -0
  15. package/dist/components/cfn-imports.js +239 -0
  16. package/dist/components/cloud-executor.d.ts +31 -2
  17. package/dist/components/cloud-executor.d.ts.map +1 -1
  18. package/dist/components/cloud-executor.js +64 -2
  19. package/dist/importable.d.ts +30 -0
  20. package/dist/importable.d.ts.map +1 -0
  21. package/dist/importable.js +72 -0
  22. package/dist/index-identity.d.ts +19 -0
  23. package/dist/index-identity.d.ts.map +1 -0
  24. package/dist/index-identity.js +79 -0
  25. package/dist/index-import.d.ts +17 -0
  26. package/dist/index-import.d.ts.map +1 -0
  27. package/dist/index-import.js +115 -0
  28. package/dist/integrity.json +3 -3
  29. package/dist/listed-types.d.ts +11 -0
  30. package/dist/listed-types.d.ts.map +1 -0
  31. package/dist/listed-types.js +52 -0
  32. package/dist/manifest.json +1 -1
  33. package/dist/meta.json +13206 -1237
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/plugin.js +9 -0
  36. package/dist/reference-catalog.d.ts.map +1 -1
  37. package/dist/reference-catalog.js +62 -20
  38. package/dist/spec/fetch.d.ts +5 -0
  39. package/dist/spec/fetch.d.ts.map +1 -1
  40. package/dist/spec/parse.d.ts +8 -0
  41. package/dist/spec/parse.d.ts.map +1 -1
  42. package/dist/spec/parse.js +3 -0
  43. package/package.json +2 -2
  44. package/src/codegen/generate-lexicon.ts +3 -0
  45. package/src/components/__tests__/mock-cloud-executor.ts +20 -1
  46. package/src/components/apply.ts +282 -29
  47. package/src/components/cfn-adopt.test.ts +284 -0
  48. package/src/components/cfn-adopt.ts +340 -0
  49. package/src/components/cfn-exports-in-use.test.ts +146 -0
  50. package/src/components/cfn-imports-preview.test.ts +178 -0
  51. package/src/components/cfn-imports-three-stacks.test.ts +107 -0
  52. package/src/components/cfn-imports.test.ts +213 -0
  53. package/src/components/cfn-imports.ts +246 -0
  54. package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
  55. package/src/components/cloud-executor.ts +102 -5
  56. package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
  57. package/src/derived-edges.test.ts +142 -0
  58. package/src/generated/lexicon-aws.json +13206 -1237
  59. package/src/importable.test.ts +86 -0
  60. package/src/importable.ts +83 -0
  61. package/src/index-identity.test.ts +134 -0
  62. package/src/index-identity.ts +98 -0
  63. package/src/index-import.test.ts +242 -0
  64. package/src/index-import.ts +140 -0
  65. package/src/index-search.test.ts +87 -0
  66. package/src/listed-types.test.ts +97 -0
  67. package/src/listed-types.ts +56 -0
  68. package/src/plugin.ts +10 -0
  69. package/src/reference-catalog.ts +63 -20
  70. package/src/spec/fetch.ts +5 -0
  71. package/src/spec/parse.test.ts +15 -0
  72. package/src/spec/parse.ts +12 -0
  73. package/src/testdata/indexed/identity.listing.json +65 -0
  74. package/src/testdata/indexed/import.listing.json +126 -0
  75. package/src/testdata/indexed/listing.json +69 -0
  76. package/src/testdata/indexed/topology.listing.json +350 -0
  77. package/src/testdata/three-cloud/dns.graph.json +336 -0
  78. package/src/three-cloud-parity.test.ts +49 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The aws lexicon's index import seam (#4524, `chant import --index`).
3
+ *
4
+ * behold's lister reads AWS through Cloud Control, which returns each
5
+ * object's CloudFormation resource model, and #4521 normalizes it with this
6
+ * lexicon's deep hooks. So an indexed node's `attrs` already are
7
+ * CloudFormation properties, and the import IR is the one the CloudFormation
8
+ * template parser writes (`{ __intrinsic: "Ref" }` and so on), which
9
+ * `CFGenerator` turns into source.
10
+ *
11
+ * - Properties the schema lists in `readOnlyProperties` (the registry's
12
+ * `attrs`) are left out, because no declaration can set them. Tags whose
13
+ * key starts with `aws:` are left out too: CloudFormation sets them and
14
+ * refuses them in a template.
15
+ * - A value that names another imported resource becomes `Ref` when it is
16
+ * that resource's Cloud Control identifier (a single-part one; for the
17
+ * types the catalog joins, Ref returns that identifier), and
18
+ * `GetAtt(<id>, "Arn")` when it is the resource's ARN and the type has an
19
+ * `Arn` attribute. Anything else stays a literal.
20
+ * - A masked value becomes a `String` parameter with `NoEcho`.
21
+ */
22
+ import { createRequire } from "module";
23
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
24
+ import type { IRNode } from "@intentius/chant/graph-ir";
25
+ import type { IndexImportCodegen, IndexImportTarget } from "@intentius/chant/lifecycle/index-import";
26
+ import type { ParameterIR } from "@intentius/chant/import/parser";
27
+
28
+ const require = createRequire(moduleSourceUrl(import.meta.url));
29
+
30
+ /** One `lexicon-aws.json` entry, the fields this seam reads. */
31
+ export interface ImportManifestEntry {
32
+ resourceType: string;
33
+ kind: string;
34
+ /** GetAtt name -> CloudFormation attribute: the schema's `readOnlyProperties`. */
35
+ attrs?: Record<string, string>;
36
+ }
37
+
38
+ let cachedManifest: ReadonlyMap<string, ImportManifestEntry> | undefined;
39
+
40
+ /** The generated registry keyed by type, or an empty map in a checkout that has not generated. */
41
+ function defaultManifest(): ReadonlyMap<string, ImportManifestEntry> {
42
+ if (cachedManifest) return cachedManifest;
43
+ try {
44
+ const manifest = require("./generated/lexicon-aws.json") as Record<string, ImportManifestEntry>;
45
+ cachedManifest = new Map(Object.values(manifest).filter((e) => e.kind === "resource").map((e) => [e.resourceType, e]));
46
+ } catch {
47
+ cachedManifest = new Map();
48
+ }
49
+ return cachedManifest;
50
+ }
51
+
52
+ const CFN_TYPE = /^AWS::[A-Za-z0-9]+::[A-Za-z0-9]+$/;
53
+
54
+ function isRecord(v: unknown): v is Record<string, unknown> {
55
+ return typeof v === "object" && v !== null && !Array.isArray(v);
56
+ }
57
+
58
+ /** Tags without the `aws:` ones, in either shape CloudFormation uses (a Key/Value list or a map). */
59
+ function withoutAwsTags(tags: unknown): unknown {
60
+ if (Array.isArray(tags)) {
61
+ return tags.filter((t) => !(isRecord(t) && typeof t.Key === "string" && t.Key.startsWith("aws:")));
62
+ }
63
+ if (isRecord(tags)) {
64
+ return Object.fromEntries(Object.entries(tags).filter(([k]) => !k.startsWith("aws:")));
65
+ }
66
+ return tags;
67
+ }
68
+
69
+ /** The properties a declaration of `type` can hold, from an indexed node's attrs. */
70
+ export function awsImportProperties(
71
+ type: string,
72
+ attrs: Record<string, unknown>,
73
+ manifest: ReadonlyMap<string, ImportManifestEntry> = defaultManifest(),
74
+ ): Record<string, unknown> {
75
+ const readOnly = new Set(Object.values(manifest.get(type)?.attrs ?? {}).map((a) => a.split(".")[0]));
76
+ const out: Record<string, unknown> = {};
77
+ for (const key of Object.keys(attrs).sort()) {
78
+ if (readOnly.has(key)) continue;
79
+ const value = key === "Tags" ? withoutAwsTags(attrs[key]) : attrs[key];
80
+ if (key === "Tags" && ((Array.isArray(value) && value.length === 0) || (isRecord(value) && Object.keys(value).length === 0))) continue;
81
+ out[key] = value;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** True when `type` exposes `Arn` to GetAtt, or when there is no registry to ask. */
87
+ function hasArnAttribute(type: string, manifest: ReadonlyMap<string, ImportManifestEntry>): boolean {
88
+ if (manifest.size === 0) return true;
89
+ return Object.values(manifest.get(type)?.attrs ?? {}).includes("Arn");
90
+ }
91
+
92
+ /** `value` as a reference to `target`, or undefined. */
93
+ export function awsImportReference(
94
+ value: string,
95
+ target: IndexImportTarget,
96
+ manifest: ReadonlyMap<string, ImportManifestEntry> = defaultManifest(),
97
+ ): unknown | undefined {
98
+ const node = target.node;
99
+ if (node.nativeId !== undefined && value === node.nativeId && !value.includes("|")) {
100
+ return { __intrinsic: "Ref", name: target.logicalId };
101
+ }
102
+ const isArn = (node.facts?.cloudIds ?? []).some((c) => c.kind === "arn" && c.value === value);
103
+ if (isArn && hasArnAttribute(node.kind, manifest)) {
104
+ return { __intrinsic: "GetAtt", logicalId: target.logicalId, attribute: "Arn" };
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** The resource attributes `chant import --index` writes on every resource it imports (#4536). */
110
+ export const ADOPTED_RESOURCE_ATTRIBUTES: Readonly<Record<string, string>> = { DeletionPolicy: "Retain", UpdateReplacePolicy: "Retain" };
111
+
112
+ export function createAwsIndexImport(manifest?: ReadonlyMap<string, ImportManifestEntry>): IndexImportCodegen {
113
+ const m = () => manifest ?? defaultManifest();
114
+ return {
115
+ resource(node: IRNode) {
116
+ if (!CFN_TYPE.test(node.kind)) return { unsupported: `${node.kind} is not a CloudFormation resource type` };
117
+ // Retain on every imported resource until a person changes it in source
118
+ // (#4536, ruled on choudoufu#2284 Q4): CloudFormation deletes at stack
119
+ // level and reads no per-resource marker, so an adopted object is never
120
+ // deleted on the stack's word alone. Import requires a DeletionPolicy.
121
+ return { type: node.kind, properties: awsImportProperties(node.kind, node.attrs ?? {}, m()), attributes: { ...ADOPTED_RESOURCE_ATTRIBUTES } };
122
+ },
123
+ reference: (value, target) => awsImportReference(value, target, m()),
124
+ secretParameter(name, context): ParameterIR {
125
+ return {
126
+ name,
127
+ type: "String",
128
+ description: `${context.path} of ${context.node.id}, masked in the index`,
129
+ constraints: { NoEcho: true },
130
+ };
131
+ },
132
+ parameterReference: (name) => ({ __intrinsic: "Ref", name }),
133
+ typeLabel(node) {
134
+ const last = node.kind.split("::").pop() ?? "Resource";
135
+ return last.charAt(0).toUpperCase() + last.slice(1);
136
+ },
137
+ };
138
+ }
139
+
140
+ export const awsIndexImport: IndexImportCodegen = createAwsIndexImport();
@@ -0,0 +1,87 @@
1
+ /**
2
+ * #4525 on the aws lexicon: `chant search --index`'s join over the identity
3
+ * fixture (#4523's, ./testdata/indexed/identity.listing.json). A declared
4
+ * source with a bucket and a role made outside chant, a bucket that does not
5
+ * exist, a queue, an instance and a table outside the index's --types, joined
6
+ * through the aws lexicon's own index match. Each row gets one verdict, and
7
+ * the overlay carries what the index read onto the bound rows only.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test } from "vitest";
12
+ import type { GraphIR, IRNode } from "@intentius/chant/graph-ir";
13
+ import type { IndexIdentity } from "@intentius/chant/lifecycle/identity-match";
14
+ import { joinDeclaredToIndex } from "@intentius/chant/lifecycle/index-join";
15
+ import { typeListing, type ListingLexicon, type ListingResult } from "@intentius/chant/lifecycle/type-listing";
16
+ import { awsDeclaredIdentity, awsIndexIdentity, type IdentityManifestEntry } from "./index-identity";
17
+ import { awsKindForListedType } from "./listed-types";
18
+ import { awsPlugin } from "./plugin";
19
+
20
+ // The manifest rows these types generate, as index-identity.test.ts reads them.
21
+ const ROWS: IdentityManifestEntry[] = [
22
+ { resourceType: "AWS::S3::Bucket", kind: "resource", primaryIdentifier: ["BucketName"], attrs: { Arn: "Arn", DomainName: "DomainName", RegionalDomainName: "RegionalDomainName", WebsiteURL: "WebsiteURL" } },
23
+ { resourceType: "AWS::IAM::Role", kind: "resource", primaryIdentifier: ["RoleName"], attrs: { Arn: "Arn", RoleId: "RoleId" } },
24
+ { resourceType: "AWS::SQS::Queue", kind: "resource", primaryIdentifier: ["QueueUrl"], attrs: { Arn: "Arn", QueueUrl: "QueueUrl" } },
25
+ { resourceType: "AWS::EC2::Instance", kind: "resource", primaryIdentifier: ["InstanceId"], attrs: { InstanceId: "InstanceId", PrivateIp: "PrivateIp", PublicIp: "PublicIp" } },
26
+ { resourceType: "AWS::DynamoDB::Table", kind: "resource", primaryIdentifier: ["TableName"], attrs: { Arn: "Arn", StreamArn: "StreamArn" } },
27
+ ];
28
+ const MANIFEST: ReadonlyMap<string, IdentityManifestEntry> = new Map(ROWS.map((e) => [e.resourceType, e]));
29
+ const identity: IndexIdentity = { ...awsIndexIdentity, declaredIdentity: (t, p) => awsDeclaredIdentity(t, p, MANIFEST) };
30
+ const KINDS: ReadonlySet<string> = new Set(MANIFEST.keys());
31
+ const aws: ListingLexicon = {
32
+ name: awsPlugin.name,
33
+ deepNormalizationHooks: awsPlugin.deepNormalizationHooks,
34
+ listedTypes: { ...awsPlugin.listedTypes!, kindFor: (t) => awsKindForListedType(t, KINDS) },
35
+ };
36
+ const listing = JSON.parse(readFileSync(join(import.meta.dirname, "testdata", "indexed", "identity.listing.json"), "utf-8")) as ListingResult;
37
+ const doc = typeListing(listing, [aws]);
38
+
39
+ const node = (name: string, kind: string, attrs: Record<string, unknown>): IRNode => ({ id: `app::${name}`, kind, lexicon: "aws", attrs });
40
+ const declared: GraphIR = {
41
+ version: 1,
42
+ nodes: [
43
+ node("assets", "AWS::S3::Bucket", { BucketName: "cli-made-assets" }),
44
+ node("runner", "AWS::IAM::Role", { RoleName: "cli-made-role", AssumeRolePolicyDocument: {} }),
45
+ node("archive", "AWS::S3::Bucket", { BucketName: "never-made-archive" }),
46
+ node("jobs", "AWS::SQS::Queue", { QueueName: "cli-made-queue" }),
47
+ node("box", "AWS::EC2::Instance", { InstanceType: "t3.micro", ImageId: "ami-0abcdef1234567890" }),
48
+ node("carts", "AWS::DynamoDB::Table", { TableName: "carts" }),
49
+ ],
50
+ edges: [],
51
+ groups: { byLexicon: {} },
52
+ };
53
+
54
+ describe("search --index's join on the aws fixture", () => {
55
+ const { ir, joins } = joinDeclaredToIndex(declared, doc, [{ name: "aws", indexIdentity: identity }], { regionOf: () => "us-east-1" });
56
+ const row = (name: string) => ir.nodes.find((n) => n.id === `app::${name}`)!;
57
+
58
+ test("the bucket and role made outside chant are bound, with what the index read about them", () => {
59
+ expect(joins["app::assets"]).toMatchObject({ verdict: "bound", node: "aws/000000000000/us-east-1/AWS::S3::Bucket/cli-made-assets", nativeId: "cli-made-assets" });
60
+ expect(joins["app::runner"]).toMatchObject({ verdict: "bound", node: "aws/000000000000/global/AWS::IAM::Role/cli-made-role", location: "global" });
61
+ expect(row("assets").physicalId).toBe("cli-made-assets");
62
+ // The aws hooks prune read-only properties such as Arn from the indexed tree
63
+ // (#4521: an indexed tree has the shape an observed one has); the index
64
+ // carries the ARN as a cloud id in the node's facts.
65
+ expect(row("assets").attrs?.BucketName).toBe("cli-made-assets");
66
+ expect(row("assets").facts?.cloudIds).toContainEqual({ kind: "arn", value: "arn:aws:s3:::cli-made-assets" });
67
+ });
68
+
69
+ test("the bucket the index listed and did not find is absent", () => {
70
+ expect(joins["app::archive"]).toMatchObject({ verdict: "absent", identifier: "never-made-archive", location: "us-east-1" });
71
+ expect(row("archive").physicalId).toBeUndefined();
72
+ });
73
+
74
+ test("the queue and the instance spell no identity: no-binding, though both are indexed", () => {
75
+ expect(joins["app::jobs"]).toMatchObject({ verdict: "no-binding", detail: expect.stringContaining("QueueUrl") });
76
+ expect(joins["app::box"]).toMatchObject({ verdict: "no-binding", detail: expect.stringContaining("InstanceId") });
77
+ expect(row("jobs").physicalId).toBeUndefined();
78
+ });
79
+
80
+ test("the table outside --types is not observed, filtered", () => {
81
+ expect(joins["app::carts"]).toMatchObject({ verdict: "not-observed", reason: "filtered", listedType: "AWS::DynamoDB::Table" });
82
+ });
83
+
84
+ test("only the declared rows are in the answer", () => {
85
+ expect(ir.nodes.map((n) => n.id).sort()).toEqual(declared.nodes.map((n) => n.id).sort());
86
+ });
87
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * #4521 on the aws lexicon: Cloud Control's type names mapped to kinds, a
3
+ * fixture listing typed into nodes, and a capability that answers the same
4
+ * over the indexed fixture as over the equivalent declared graph.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { describe, expect, it } from "vitest";
9
+ import type { GraphIR } from "@intentius/chant/graph-ir";
10
+ import { typeListing, type ListingLexicon, type ListingResult } from "@intentius/chant/lifecycle/type-listing";
11
+ import { CapabilityEngine } from "@intentius/chant/capabilities/matcher";
12
+ import { buildView, displayName } from "@intentius/chant/capabilities/view";
13
+ import { resolvePacks, shippedRules } from "@intentius/chant/capabilities/catalog";
14
+ import { MASKED } from "@intentius/chant/deep-observation";
15
+ import { awsCapabilityPack } from "./capabilities/pack";
16
+ import { awsKindForListedType, awsListedTypeForKind } from "./listed-types";
17
+ import { awsPlugin } from "./plugin";
18
+
19
+ const here = import.meta.dirname;
20
+ const surface = JSON.parse(readFileSync(join(here, "..", "surface.snapshot.json"), "utf-8")) as {
21
+ entries: Record<string, { kind: string; resourceType: string }>;
22
+ };
23
+ const KINDS: ReadonlySet<string> = new Set(
24
+ Object.values(surface.entries).filter((e) => e.kind === "resource").map((e) => e.resourceType),
25
+ );
26
+ const listing = JSON.parse(readFileSync(join(here, "testdata", "indexed", "listing.json"), "utf-8")) as ListingResult;
27
+
28
+ // The plugin's own hooks, with the kind set pinned to the checked-in surface so
29
+ // the test does not depend on whether `chant generate` has run.
30
+ const aws: ListingLexicon = {
31
+ name: awsPlugin.name,
32
+ deepNormalizationHooks: awsPlugin.deepNormalizationHooks,
33
+ listedTypes: { ...awsPlugin.listedTypes!, kindFor: (t) => awsKindForListedType(t, KINDS) },
34
+ };
35
+
36
+ describe("Cloud Control types to aws kinds", () => {
37
+ it("every aws resource kind has a listed type that maps back to it", () => {
38
+ const missing = [...KINDS].filter((k) => awsKindForListedType(awsListedTypeForKind(k), KINDS) !== k);
39
+ expect(missing).toEqual([]);
40
+ expect(KINDS.size).toBeGreaterThan(1000);
41
+ });
42
+
43
+ it("a type outside the lexicon, or not a CloudFormation name, has no kind", () => {
44
+ expect(awsKindForListedType("AWS::IoTFleetHub::Application", KINDS)).toBeUndefined();
45
+ expect(awsKindForListedType("aws_s3_bucket", KINDS)).toBeUndefined();
46
+ expect(awsKindForListedType("Alexa::ASK::Skill", KINDS)).toBe("Alexa::ASK::Skill");
47
+ });
48
+
49
+ it("the plugin declares the mapping for the aws substrate", () => {
50
+ expect(awsPlugin.listedTypes?.substrate).toBe("aws");
51
+ });
52
+ });
53
+
54
+ describe("the aws fixture listing as typed nodes", () => {
55
+ const doc = typeListing(listing, [aws]);
56
+
57
+ it("produces a typed node per mapped object, and an unknown-kind node for the rest", () => {
58
+ expect(doc.graph.nodes.map((n) => [n.nativeId, n.kind, n.unmappedKind ?? false])).toEqual([
59
+ ["vol-0a1b2c3d4e5f60718", "AWS::EC2::Volume", false],
60
+ ["fleet-console", "AWS::IoTFleetHub::Application", true],
61
+ ["orders-db", "AWS::RDS::DBInstance", false],
62
+ ["reports-db", "AWS::RDS::DBInstance", false],
63
+ ["shop-assets", "AWS::S3::Bucket", false],
64
+ ]);
65
+ expect(doc.graph.nodes).toHaveLength(listing.resources.length);
66
+ });
67
+
68
+ it("normalizes through the aws hooks: read-only properties pruned, secrets masked", () => {
69
+ const bucket = doc.graph.nodes.find((n) => n.nativeId === "shop-assets")!;
70
+ expect(bucket.attrs).toEqual({ BucketName: "shop-assets" });
71
+ const db = doc.graph.nodes.find((n) => n.nativeId === "orders-db")!;
72
+ expect(db.attrs.MasterUserPassword).toBe(MASKED);
73
+ expect(db.attrs.StorageEncrypted).toBe(true);
74
+ expect(db.facts?.markers).toEqual({ owner: "shop", encoding: "escaped", source: "live" });
75
+ });
76
+
77
+ it("encrypted-at-rest matches the same resources over the index as over the declared graph", () => {
78
+ // The declared form of the same three resources, as source would carry them.
79
+ const declared: GraphIR = {
80
+ nodes: [
81
+ { id: "orders-db", kind: "AWS::RDS::DBInstance", lexicon: "aws", attrs: { DBInstanceIdentifier: "orders-db", Engine: "postgres", StorageEncrypted: true } },
82
+ { id: "reports-db", kind: "AWS::RDS::DBInstance", lexicon: "aws", attrs: { DBInstanceIdentifier: "reports-db", Engine: "postgres", StorageEncrypted: false } },
83
+ { id: "vol-0a1b2c3d4e5f60718", kind: "AWS::EC2::Volume", lexicon: "aws", attrs: { AvailabilityZone: "us-east-1a", Size: 20, Encrypted: true } },
84
+ { id: "shop-assets", kind: "AWS::S3::Bucket", lexicon: "aws", attrs: { BucketName: "shop-assets" } },
85
+ ],
86
+ edges: [],
87
+ groups: {},
88
+ };
89
+ const answer = (g: GraphIR) => {
90
+ const e = new CapabilityEngine(buildView(g, resolvePacks([awsCapabilityPack])), shippedRules().rules);
91
+ return [...e.set("encrypted-at-rest", e.bind("encrypted-at-rest", [] as never)).keys()].map(displayName).sort();
92
+ };
93
+ // aws.paths: S3 buckets are always encrypted at rest; the unencrypted database is the one left out.
94
+ expect(answer(declared)).toEqual(["orders-db", "shop-assets", "vol-0a1b2c3d4e5f60718"]);
95
+ expect(answer(doc.graph)).toEqual(answer(declared));
96
+ });
97
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Cloud Control's type names as aws lexicon kinds (#4521).
3
+ *
4
+ * behold#1029 lists AWS through Cloud Control `ListResources`, which names
5
+ * each object by its CloudFormation type (`AWS::S3::Bucket`), and the aws
6
+ * lexicon's kinds are those same names. So the mapping is the identity, kept
7
+ * to the types the lexicon generated: a type outside `lexicon-aws.json` (one
8
+ * newer than the pinned registry, or a third-party type) has no kind here and
9
+ * stays an unknown-kind node. Cloud Control returns the CloudFormation
10
+ * resource model, which is the tree the deep reader returns too
11
+ * (./deep-observe.ts), so no reshaping.
12
+ */
13
+ import { createRequire } from "module";
14
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
15
+ import type { ListedTypeMapping } from "@intentius/chant/lifecycle/type-listing";
16
+
17
+ const require = createRequire(moduleSourceUrl(import.meta.url));
18
+
19
+ // A CloudFormation type name: `<Organization>::<Service>::<Resource>`. The
20
+ // organization is not always AWS (`Alexa::ASK::Skill`), so it is not pinned.
21
+ const CFN_TYPE = /^[A-Za-z0-9]+::[A-Za-z0-9]+::[A-Za-z0-9]+$/;
22
+
23
+ let cachedKinds: ReadonlySet<string> | null | undefined;
24
+
25
+ /** The resource kinds `chant generate` wrote, or null in a checkout that has not generated. */
26
+ function generatedKinds(): ReadonlySet<string> | null {
27
+ if (cachedKinds !== undefined) return cachedKinds;
28
+ try {
29
+ const manifest = require("./generated/lexicon-aws.json") as Record<string, { resourceType: string; kind: string }>;
30
+ cachedKinds = new Set(Object.values(manifest).filter((e) => e.kind === "resource").map((e) => e.resourceType));
31
+ } catch {
32
+ cachedKinds = null;
33
+ }
34
+ return cachedKinds;
35
+ }
36
+
37
+ /**
38
+ * The aws kind for a Cloud Control type, or undefined. With no kind set (no
39
+ * generated registry) every well-formed CloudFormation type maps, which is
40
+ * what the rest of the lexicon assumes before `chant generate` has run.
41
+ */
42
+ export function awsKindForListedType(listedType: string, kinds: ReadonlySet<string> | null = generatedKinds()): string | undefined {
43
+ if (!CFN_TYPE.test(listedType)) return undefined;
44
+ if (kinds && !kinds.has(listedType)) return undefined;
45
+ return listedType;
46
+ }
47
+
48
+ /** The inverse, for tests and for a reader naming what to list: the same name. */
49
+ export function awsListedTypeForKind(kind: string): string {
50
+ return kind;
51
+ }
52
+
53
+ export const awsListedTypes: ListedTypeMapping = {
54
+ substrate: "aws",
55
+ kindFor: (listedType) => awsKindForListedType(listedType),
56
+ };
package/src/plugin.ts CHANGED
@@ -3,6 +3,9 @@ import { createRequire } from "module";
3
3
  import { awsBehaviourKinds } from "./behaviour-kinds";
4
4
  import { detectTemplate } from "./detect";
5
5
  import { awsCapabilityPack } from "./capabilities/pack";
6
+ import { awsListedTypes } from "./listed-types";
7
+ import { awsIndexIdentity } from "./index-identity";
8
+ import { awsIndexImport } from "./index-import";
6
9
  import type { LexiconPlugin, IntrinsicDef, ObservationResult, DeepObservationResult, DependencyObservation, DescribeIdentityOptions, DescribeIdentityResult, DisruptionQuery, DisruptionVerdict, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
7
10
  const require = createRequire(moduleSourceUrl(import.meta.url));
8
11
  import type { LintRule } from "@intentius/chant/lint/rule";
@@ -98,6 +101,13 @@ export const awsPlugin: LexiconPlugin = {
98
101
  referenceCatalog: awsReferenceCatalog,
99
102
  capabilityPack: awsCapabilityPack,
100
103
 
104
+ // Cloud inventory type names to kinds, for an indexed resource (#4521).
105
+ listedTypes: awsListedTypes,
106
+ // Declared entities to indexed resources by primaryIdentifier (#4523).
107
+ indexIdentity: awsIndexIdentity,
108
+ // Indexed resources as CloudFormation import IR, for `chant import --index` (#4524).
109
+ indexImport: awsIndexImport,
110
+
101
111
  lintRules(): LintRule[] {
102
112
  const rulesDir = join(dirname(fileURLToPath(import.meta.url)), "lint", "rules");
103
113
  return discoverLintRules(rulesDir, import.meta.url);
@@ -28,6 +28,22 @@ export const awsReferenceCatalog: ReferenceCatalog = {
28
28
  { kind: "AWS::ECS::Service", ids: ["ServiceArn"] },
29
29
  { kind: "AWS::ECS::TaskDefinition", ids: ["TaskDefinitionArn"] },
30
30
  { kind: "AWS::RDS::DBInstance", ids: ["DBInstanceArn", "Endpoint.Address"] },
31
+ // Targets of the CloudFormation-shape rules an index needs (#4522, the
32
+ // missing rules counted on INTENTIUS/behold#1023). Over an index each
33
+ // node is also matched by its Cloud Control identifier and its cloud ids,
34
+ // so a role's ARN joins even though the deep hooks prune `Arn`.
35
+ { kind: "AWS::RDS::DBSubnetGroup", ids: ["DBSubnetGroupName"] },
36
+ { kind: "AWS::IAM::Role", ids: ["Arn", "RoleName"] },
37
+ { kind: "AWS::EC2::EIP", ids: ["AllocationId", "PublicIp"] },
38
+ { kind: "AWS::Logs::LogGroup", ids: ["LogGroupName", "Arn"] },
39
+ { kind: "AWS::Route53::HostedZone", ids: ["Id"] },
40
+ { kind: "AWS::EKS::Cluster", ids: ["Name", "Arn"] },
41
+ { kind: "AWS::CloudFormation::Stack", ids: ["StackId"] },
42
+ // #4535: a record set is named by its record name, which is what a CNAME
43
+ // in the same zone points at; a bucket by its name, which is what a
44
+ // workload's env var carries (behold#166, ../../k8s/src/reference-catalog.ts).
45
+ { kind: "AWS::Route53::RecordSet", ids: ["Name"] },
46
+ { kind: "AWS::S3::Bucket", ids: ["BucketName"] },
31
47
  ],
32
48
  refs: [
33
49
  // ── containment (→ boundary boxes, #779) ──
@@ -86,25 +102,52 @@ export const awsReferenceCatalog: ReferenceCatalog = {
86
102
  // intrinsics to bare logical ids (= node ids), so these match by node id; the
87
103
  // property names are the CFN template shape (SecurityGroupIds, Cluster, …),
88
104
  // which differs from the describe/SDK shape above. No targetKind needed —
89
- // logical ids are unique.
90
- { from: "AWS::EC2::Subnet", path: "VpcId", relation: "containment", label: "in VPC" },
91
- { from: "AWS::EC2::SecurityGroup", path: "VpcId", relation: "containment", label: "in VPC" },
92
- { from: "AWS::EC2::RouteTable", path: "VpcId", relation: "containment", label: "in VPC" },
93
- { from: "AWS::EC2::Instance", path: "SubnetId", relation: "containment", label: "in subnet" },
94
- { from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", relation: "reference", label: "sg" },
95
- { from: "AWS::EC2::NatGateway", path: "SubnetId", relation: "containment", label: "in subnet" },
96
- { from: "AWS::EC2::Route", path: "RouteTableId", relation: "reference", label: "in" },
97
- { from: "AWS::EC2::Route", path: "GatewayId", relation: "reference", label: "via" },
98
- { from: "AWS::EC2::Route", path: "NatGatewayId", relation: "reference", label: "via" },
99
- { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "Subnets[]", relation: "containment", label: "in subnet" },
100
- { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", relation: "reference", label: "sg" },
101
- { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", relation: "containment", label: "in VPC" },
102
- { from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", relation: "reference", label: "on" },
103
- { from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", relation: "reference", label: "forwards to" },
104
- { from: "AWS::ECS::Service", path: "Cluster", relation: "reference", label: "in cluster" },
105
- { from: "AWS::ECS::Service", path: "TaskDefinition", relation: "reference", label: "runs" },
106
- { from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", relation: "reference", label: "registered in" },
107
- { from: "AWS::RDS::DBInstance", path: "DBSubnetGroupName", relation: "reference", label: "subnets" },
108
- { from: "AWS::RDS::DBInstance", path: "VPCSecurityGroups[]", relation: "reference", label: "sg" },
105
+ // logical ids are unique. Over an index (#4522) the same paths hold physical
106
+ // values, some of them names, so each carries the `indexedTargetKind` it
107
+ // takes there (ruled on INTENTIUS/behold#1023 Q4).
108
+ { from: "AWS::EC2::Subnet", path: "VpcId", indexedTargetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
109
+ { from: "AWS::EC2::SecurityGroup", path: "VpcId", indexedTargetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
110
+ { from: "AWS::EC2::RouteTable", path: "VpcId", indexedTargetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
111
+ { from: "AWS::EC2::Instance", path: "SubnetId", indexedTargetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
112
+ { from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", indexedTargetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
113
+ { from: "AWS::EC2::NatGateway", path: "SubnetId", indexedTargetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
114
+ { from: "AWS::EC2::Route", path: "RouteTableId", indexedTargetKind: "AWS::EC2::RouteTable", relation: "reference", label: "in" },
115
+ { from: "AWS::EC2::Route", path: "GatewayId", indexedTargetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "via" },
116
+ { from: "AWS::EC2::Route", path: "NatGatewayId", indexedTargetKind: "AWS::EC2::NatGateway", relation: "reference", label: "via" },
117
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "Subnets[]", indexedTargetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
118
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", indexedTargetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
119
+ { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", indexedTargetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
120
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", indexedTargetKind: "AWS::ElasticLoadBalancingV2::LoadBalancer", relation: "reference", label: "on" },
121
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", indexedTargetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "forwards to" },
122
+ { from: "AWS::ECS::Service", path: "Cluster", indexedTargetKind: "AWS::ECS::Cluster", relation: "reference", label: "in cluster" },
123
+ { from: "AWS::ECS::Service", path: "TaskDefinition", indexedTargetKind: "AWS::ECS::TaskDefinition", relation: "reference", label: "runs" },
124
+ { from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", indexedTargetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "registered in" },
125
+ { from: "AWS::RDS::DBInstance", path: "DBSubnetGroupName", indexedTargetKind: "AWS::RDS::DBSubnetGroup", relation: "reference", label: "subnets" },
126
+ { from: "AWS::RDS::DBInstance", path: "VPCSecurityGroups[]", indexedTargetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
127
+
128
+ // ── CloudFormation property shape, as Cloud Control returns it (#4522) ──
129
+ // The rules INTENTIUS/behold#1023's count found missing for an index of
130
+ // example-three-cloud. Targeted, so they hold over physical values and over
131
+ // logical ids alike.
132
+ { from: "AWS::EC2::VPCGatewayAttachment", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "reference", label: "attaches to" },
133
+ { from: "AWS::EC2::VPCGatewayAttachment", path: "InternetGatewayId", targetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "attaches" },
134
+ { from: "AWS::EC2::NatGateway", path: "AllocationId", targetKind: "AWS::EC2::EIP", relation: "reference", label: "address" },
135
+ { from: "AWS::ECS::TaskDefinition", path: "ExecutionRoleArn", targetKind: "AWS::IAM::Role", relation: "reference", label: "execution role" },
136
+ { from: "AWS::ECS::TaskDefinition", path: "TaskRoleArn", targetKind: "AWS::IAM::Role", relation: "reference", label: "task role" },
137
+ { from: "AWS::ECS::TaskDefinition", path: "ContainerDefinitions[].LogConfiguration.Options.awslogs-group", targetKind: "AWS::Logs::LogGroup", relation: "reference", label: "logs to" },
138
+ { from: "AWS::EC2::SecurityGroup", path: "SecurityGroupIngress[].SourceSecurityGroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "allows" },
139
+ { from: "AWS::Route53::RecordSet", path: "HostedZoneId", targetKind: "AWS::Route53::HostedZone", relation: "containment", label: "in zone" },
140
+ // A CNAME to another record of the estate (#4535: example-three-cloud's
141
+ // latency record for gcp points at gcp.three-cloud.example.). Optional,
142
+ // since most record values are addresses or hostnames outside it.
143
+ { from: "AWS::Route53::RecordSet", path: "ResourceRecords[]", targetKind: "AWS::Route53::RecordSet", relation: "reference", label: "aliases", optional: true },
144
+ ],
145
+ // Tags that name a cloud id in their key or value (behold#1023 Q5). Used
146
+ // over indexed nodes only. No rule matches equal tag values in general.
147
+ tagRefs: [
148
+ { key: "aws:cloudformation:stack-id", read: "value", targetKind: "AWS::CloudFormation::Stack", label: "in stack" },
149
+ { key: "aws:eks:cluster-name", read: "value", targetKind: "AWS::EKS::Cluster", label: "cluster" },
150
+ { key: "eks:cluster-name", read: "value", targetKind: "AWS::EKS::Cluster", label: "cluster" },
151
+ { key: "kubernetes.io/cluster/", read: "key-suffix", targetKind: "AWS::EKS::Cluster", label: "cluster" },
109
152
  ],
110
153
  };
package/src/spec/fetch.ts CHANGED
@@ -33,6 +33,11 @@ export interface CFNSchema {
33
33
  cloudFormationSystemTags?: boolean;
34
34
  tagProperty?: string;
35
35
  };
36
+ /**
37
+ * The resource provider's handlers (`create`, `read`, `update`, `delete`,
38
+ * `list`), each with its IAM permissions. A legacy type has none (#4536).
39
+ */
40
+ handlers?: Record<string, unknown>;
36
41
  additionalProperties?: boolean;
37
42
  }
38
43
 
@@ -283,6 +283,21 @@ describe("parseCFNSchema", () => {
283
283
  });
284
284
  });
285
285
 
286
+ test("keeps the handlers section as its sorted operation names (#4536)", () => {
287
+ const schema = JSON.stringify({
288
+ typeName: "AWS::Test::Handled",
289
+ properties: { Name: { type: "string" } },
290
+ handlers: { read: { permissions: ["test:Get"] }, create: { permissions: ["test:Create"] }, delete: { permissions: [] } },
291
+ additionalProperties: false,
292
+ });
293
+ expect(parseCFNSchema(schema).resource.handlers).toEqual(["create", "delete", "read"]);
294
+ });
295
+
296
+ test("omits handlers when the schema has none, as a legacy type does (#4536)", () => {
297
+ const schema = JSON.stringify({ typeName: "AWS::Test::Legacy", properties: { Name: { type: "string" } }, additionalProperties: false });
298
+ expect(parseCFNSchema(schema).resource.handlers).toBeUndefined();
299
+ });
300
+
286
301
  test("omits tagging when not taggable", () => {
287
302
  const schema = JSON.stringify({
288
303
  typeName: "AWS::Test::NotTaggable",
package/src/spec/parse.ts CHANGED
@@ -80,6 +80,14 @@ export interface ParsedResource {
80
80
  conditionalCreateOnly: string[];
81
81
  replacementStrategy?: "delete_then_create" | "create_then_delete";
82
82
  tagging?: ResourceTagging;
83
+ /**
84
+ * The schema's `handlers` section as its operation names, sorted
85
+ * (`["create", "delete", "list", "read", "update"]`). The permissions each
86
+ * handler lists are left out. Absent when the schema has no handlers, as a
87
+ * legacy type does. CloudFormation can import a type with `create` and
88
+ * `read` (../importable.ts, #4536).
89
+ */
90
+ handlers?: string[];
83
91
  }
84
92
 
85
93
  /** How a type's tags are written: a `[{Key, Value}]` list or a key-to-value map. */
@@ -223,6 +231,9 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
223
231
  };
224
232
  }
225
233
 
234
+ // --- Handlers (#4536): which operations the resource provider implements ---
235
+ const handlers = schema.handlers && typeof schema.handlers === "object" ? Object.keys(schema.handlers).sort() : [];
236
+
226
237
  // --- Replacement strategy ---
227
238
  let replacementStrategy: ParsedResource["replacementStrategy"];
228
239
  if (schema.replacementStrategy === "delete_then_create" || schema.replacementStrategy === "create_then_delete") {
@@ -252,6 +263,7 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
252
263
  conditionalCreateOnly: stripPointerPaths(schema.conditionalCreateOnlyProperties ?? [], schema),
253
264
  ...(replacementStrategy && { replacementStrategy }),
254
265
  ...(tagging && { tagging }),
266
+ ...(handlers.length > 0 && { handlers }),
255
267
  },
256
268
  propertyTypes: boundedPropertyTypes,
257
269
  enums,