@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.
- package/dist/codegen/generate-lexicon.d.ts +2 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.js +1 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
- package/dist/components/__tests__/mock-cloud-executor.js +18 -1
- package/dist/components/apply.d.ts +44 -2
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +260 -29
- package/dist/components/cfn-adopt.d.ts +99 -0
- package/dist/components/cfn-adopt.d.ts.map +1 -0
- package/dist/components/cfn-adopt.js +278 -0
- package/dist/components/cfn-imports.d.ts +84 -0
- package/dist/components/cfn-imports.d.ts.map +1 -0
- package/dist/components/cfn-imports.js +239 -0
- package/dist/components/cloud-executor.d.ts +31 -2
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +64 -2
- package/dist/importable.d.ts +30 -0
- package/dist/importable.d.ts.map +1 -0
- package/dist/importable.js +72 -0
- package/dist/index-identity.d.ts +19 -0
- package/dist/index-identity.d.ts.map +1 -0
- package/dist/index-identity.js +79 -0
- package/dist/index-import.d.ts +17 -0
- package/dist/index-import.d.ts.map +1 -0
- package/dist/index-import.js +115 -0
- package/dist/integrity.json +3 -3
- package/dist/listed-types.d.ts +11 -0
- package/dist/listed-types.d.ts.map +1 -0
- package/dist/listed-types.js +52 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +13206 -1237
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +9 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/reference-catalog.js +62 -20
- package/dist/spec/fetch.d.ts +5 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +8 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/parse.js +3 -0
- package/package.json +2 -2
- package/src/codegen/generate-lexicon.ts +3 -0
- package/src/components/__tests__/mock-cloud-executor.ts +20 -1
- package/src/components/apply.ts +282 -29
- package/src/components/cfn-adopt.test.ts +284 -0
- package/src/components/cfn-adopt.ts +340 -0
- package/src/components/cfn-exports-in-use.test.ts +146 -0
- package/src/components/cfn-imports-preview.test.ts +178 -0
- package/src/components/cfn-imports-three-stacks.test.ts +107 -0
- package/src/components/cfn-imports.test.ts +213 -0
- package/src/components/cfn-imports.ts +246 -0
- package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
- package/src/components/cloud-executor.ts +102 -5
- package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
- package/src/derived-edges.test.ts +142 -0
- package/src/generated/lexicon-aws.json +13206 -1237
- package/src/importable.test.ts +86 -0
- package/src/importable.ts +83 -0
- package/src/index-identity.test.ts +134 -0
- package/src/index-identity.ts +98 -0
- package/src/index-import.test.ts +242 -0
- package/src/index-import.ts +140 -0
- package/src/index-search.test.ts +87 -0
- package/src/listed-types.test.ts +97 -0
- package/src/listed-types.ts +56 -0
- package/src/plugin.ts +10 -0
- package/src/reference-catalog.ts +63 -20
- package/src/spec/fetch.ts +5 -0
- package/src/spec/parse.test.ts +15 -0
- package/src/spec/parse.ts +12 -0
- package/src/testdata/indexed/identity.listing.json +65 -0
- package/src/testdata/indexed/import.listing.json +126 -0
- package/src/testdata/indexed/listing.json +69 -0
- package/src/testdata/indexed/topology.listing.json +350 -0
- package/src/testdata/three-cloud/dns.graph.json +336 -0
- 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);
|
package/src/reference-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
{ from: "AWS::EC2::
|
|
93
|
-
{ from: "AWS::EC2::
|
|
94
|
-
{ from: "AWS::EC2::
|
|
95
|
-
{ from: "AWS::EC2::
|
|
96
|
-
{ from: "AWS::EC2::
|
|
97
|
-
{ from: "AWS::EC2::
|
|
98
|
-
{ from: "AWS::EC2::Route", path: "
|
|
99
|
-
{ from: "AWS::
|
|
100
|
-
{ from: "AWS::
|
|
101
|
-
{ from: "AWS::ElasticLoadBalancingV2::
|
|
102
|
-
{ from: "AWS::ElasticLoadBalancingV2::
|
|
103
|
-
{ from: "AWS::ElasticLoadBalancingV2::
|
|
104
|
-
{ from: "AWS::
|
|
105
|
-
{ from: "AWS::
|
|
106
|
-
{ from: "AWS::ECS::Service", path: "
|
|
107
|
-
{ from: "AWS::
|
|
108
|
-
{ from: "AWS::
|
|
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
|
|
package/src/spec/parse.test.ts
CHANGED
|
@@ -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,
|