@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4536: which CloudFormation types chant can import, from the registry
|
|
3
|
+
* `handlers` codegen keeps, plus AWS's hand lists; and the adoption
|
|
4
|
+
* refusals (not importable, create-only tags). Over an injected manifest.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { NamingStrategy } from "./codegen/naming";
|
|
8
|
+
import { generateLexiconJSON } from "./codegen/generate-lexicon";
|
|
9
|
+
import { parseCFNSchema } from "./spec/parse";
|
|
10
|
+
import {
|
|
11
|
+
adoptionRefusal,
|
|
12
|
+
isImportable,
|
|
13
|
+
LEGACY_IMPORTABLE_TYPES,
|
|
14
|
+
NOT_IMPORTABLE_TYPES,
|
|
15
|
+
type ImportabilityManifestEntry,
|
|
16
|
+
} from "./importable";
|
|
17
|
+
|
|
18
|
+
const entry = (resourceType: string, extra: Partial<ImportabilityManifestEntry> = {}): [string, ImportabilityManifestEntry] => [
|
|
19
|
+
resourceType,
|
|
20
|
+
{ resourceType, kind: "resource", ...extra },
|
|
21
|
+
];
|
|
22
|
+
const ALL = ["create", "delete", "list", "read", "update"];
|
|
23
|
+
const manifest = new Map<string, ImportabilityManifestEntry>([
|
|
24
|
+
entry("AWS::S3::Bucket", { handlers: ALL, tagging: { taggable: true, tagUpdatable: true } }),
|
|
25
|
+
entry("AWS::EC2::KeyPair", { handlers: ALL, tagging: { taggable: true, tagUpdatable: false } }),
|
|
26
|
+
entry("AWS::IAM::Policy", { handlers: ["create", "delete", "update"] }),
|
|
27
|
+
entry("AWS::CodeBuild::Project"),
|
|
28
|
+
entry("AWS::AppSync::GraphQLApi", { handlers: ALL }),
|
|
29
|
+
entry("AWS::Logs::LogStream", { handlers: ["create", "read", "delete"] }),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
describe("isImportable (#4536)", () => {
|
|
33
|
+
test("a type with create and read handlers is importable", () => {
|
|
34
|
+
expect(isImportable("AWS::S3::Bucket", manifest)).toBe(true);
|
|
35
|
+
expect(isImportable("AWS::Logs::LogStream", manifest)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a type without read, or a legacy type with no handlers, is not", () => {
|
|
39
|
+
expect(isImportable("AWS::IAM::Policy", manifest)).toBe(false);
|
|
40
|
+
expect(isImportable("AWS::CodeBuild::Project", manifest)).toBe(false);
|
|
41
|
+
expect(isImportable("AWS::Nope::Nope", manifest)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("AWS's hand lists win over the handlers", () => {
|
|
45
|
+
expect(isImportable("AWS::AppSync::GraphQLApi", manifest)).toBe(false);
|
|
46
|
+
for (const t of LEGACY_IMPORTABLE_TYPES) expect(isImportable(t, manifest)).toBe(true);
|
|
47
|
+
for (const t of NOT_IMPORTABLE_TYPES) expect(LEGACY_IMPORTABLE_TYPES.has(t)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("adoptionRefusal (#4536)", () => {
|
|
52
|
+
test("an importable type with updatable tags is adopted", () => {
|
|
53
|
+
expect(adoptionRefusal("AWS::S3::Bucket", manifest)).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("create-only tags are refused by name", () => {
|
|
57
|
+
expect(adoptionRefusal("AWS::EC2::KeyPair", manifest)).toMatch(/AWS::EC2::KeyPair's tags can only be set at create/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a type CloudFormation cannot import, or one the lexicon does not know, is refused", () => {
|
|
61
|
+
expect(adoptionRefusal("AWS::IAM::Policy", manifest)).toMatch(/no create and read handlers/);
|
|
62
|
+
expect(adoptionRefusal("AWS::AppSync::GraphQLApi", manifest)).toMatch(/not supporting resource import/);
|
|
63
|
+
expect(adoptionRefusal("AWS::Nope::Nope", manifest)).toMatch(/not a type this aws lexicon generated/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("the lexicon generator keeps handlers (#4536)", () => {
|
|
68
|
+
test("lexicon-aws.json carries each type's handler operations, and none for a legacy type", () => {
|
|
69
|
+
const results = [
|
|
70
|
+
parseCFNSchema(JSON.stringify({
|
|
71
|
+
typeName: "AWS::Test::Handled",
|
|
72
|
+
properties: { Name: { type: "string" } },
|
|
73
|
+
primaryIdentifier: ["/properties/Name"],
|
|
74
|
+
handlers: { create: { permissions: ["t:C"] }, read: { permissions: ["t:R"] }, delete: { permissions: [] } },
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
})),
|
|
77
|
+
parseCFNSchema(JSON.stringify({ typeName: "AWS::Test::Legacy", properties: { Name: { type: "string" } }, additionalProperties: false })),
|
|
78
|
+
];
|
|
79
|
+
const json = JSON.parse(generateLexiconJSON(results, new NamingStrategy(results), new Map(), null)) as Record<string, ImportabilityManifestEntry>;
|
|
80
|
+
const byType = new Map(Object.values(json).filter((e) => e.kind === "resource").map((e) => [e.resourceType, e]));
|
|
81
|
+
expect(byType.get("AWS::Test::Handled")?.handlers).toEqual(["create", "delete", "read"]);
|
|
82
|
+
expect(byType.get("AWS::Test::Legacy")?.handlers).toBeUndefined();
|
|
83
|
+
expect(isImportable("AWS::Test::Handled", byType)).toBe(true);
|
|
84
|
+
expect(isImportable("AWS::Test::Legacy", byType)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CloudFormation types chant can adopt into a stack with an IMPORT
|
|
3
|
+
* change set (#4536; ruled on INTENTIUS/choudoufu#2284 Q5, 2026-10-26).
|
|
4
|
+
*
|
|
5
|
+
* A registry type is importable when its schema has `create` and `read`
|
|
6
|
+
* handlers, which codegen keeps in `lexicon-aws.json` as `handlers`. AWS
|
|
7
|
+
* documents the exceptions on its "Resource type support" page
|
|
8
|
+
* (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resource-import-supported-resources.html),
|
|
9
|
+
* and the two short lists below are those exceptions, compared on 2026-10-26
|
|
10
|
+
* against the pinned spec (sha256:398259716cca…): one legacy type with no
|
|
11
|
+
* handlers that the page marks importable, and three types with create and
|
|
12
|
+
* read handlers that the page marks not importable.
|
|
13
|
+
*
|
|
14
|
+
* Adoption also needs to write chant's markers after the import, as a
|
|
15
|
+
* tags-only UPDATE. A type whose tags can only be set at create
|
|
16
|
+
* (`tagging.tagUpdatable` false) is refused by {@link adoptionRefusal},
|
|
17
|
+
* never replaced to mark it.
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from "module";
|
|
20
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
21
|
+
|
|
22
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
23
|
+
|
|
24
|
+
/** The slice of a `lexicon-aws.json` entry this module reads. */
|
|
25
|
+
export interface ImportabilityManifestEntry {
|
|
26
|
+
resourceType: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
handlers?: readonly string[];
|
|
29
|
+
primaryIdentifier?: readonly string[];
|
|
30
|
+
tagging?: { taggable: boolean; tagOnCreate?: boolean; tagUpdatable: boolean; tagProperty?: string; tagShape?: "list" | "map" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Legacy types (no handlers in the registry schema) that AWS documents as importable. */
|
|
34
|
+
export const LEGACY_IMPORTABLE_TYPES: ReadonlySet<string> = new Set(["AWS::IoTEvents::AlarmModel"]);
|
|
35
|
+
|
|
36
|
+
/** Types with create and read handlers that AWS documents as not importable. */
|
|
37
|
+
export const NOT_IMPORTABLE_TYPES: ReadonlySet<string> = new Set([
|
|
38
|
+
"AWS::AppSync::GraphQLApi",
|
|
39
|
+
"AWS::SES::ConfigurationSetEventDestination",
|
|
40
|
+
"AWS::SageMaker::Endpoint",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
let cachedManifest: ReadonlyMap<string, ImportabilityManifestEntry> | undefined;
|
|
44
|
+
|
|
45
|
+
/** The generated registry keyed by type, or an empty map in a checkout that has not generated. */
|
|
46
|
+
export function importabilityManifest(): ReadonlyMap<string, ImportabilityManifestEntry> {
|
|
47
|
+
if (cachedManifest) return cachedManifest;
|
|
48
|
+
try {
|
|
49
|
+
const manifest = require("./generated/lexicon-aws.json") as Record<string, ImportabilityManifestEntry>;
|
|
50
|
+
cachedManifest = new Map(Object.values(manifest).filter((e) => e.kind === "resource").map((e) => [e.resourceType, e]));
|
|
51
|
+
} catch {
|
|
52
|
+
cachedManifest = new Map();
|
|
53
|
+
}
|
|
54
|
+
return cachedManifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether CloudFormation can import the type into a stack: create and read handlers, or AWS's hand list. */
|
|
58
|
+
export function isImportable(type: string, manifest: ReadonlyMap<string, ImportabilityManifestEntry> = importabilityManifest()): boolean {
|
|
59
|
+
if (NOT_IMPORTABLE_TYPES.has(type)) return false;
|
|
60
|
+
if (LEGACY_IMPORTABLE_TYPES.has(type)) return true;
|
|
61
|
+
const handlers = manifest.get(type)?.handlers ?? [];
|
|
62
|
+
return handlers.includes("create") && handlers.includes("read");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why chant refuses to adopt the type, or undefined when it can: not a
|
|
67
|
+
* generated type, not importable, or tags that can only be set at create
|
|
68
|
+
* (the marker UPDATE would replace the object). An untaggable type is
|
|
69
|
+
* adopted on the stack's word alone; the caller says so in its review.
|
|
70
|
+
*/
|
|
71
|
+
export function adoptionRefusal(type: string, manifest: ReadonlyMap<string, ImportabilityManifestEntry> = importabilityManifest()): string | undefined {
|
|
72
|
+
const entry = manifest.get(type);
|
|
73
|
+
if (!entry && !LEGACY_IMPORTABLE_TYPES.has(type)) return `${type} is not a type this aws lexicon generated`;
|
|
74
|
+
if (!isImportable(type, manifest)) {
|
|
75
|
+
return NOT_IMPORTABLE_TYPES.has(type)
|
|
76
|
+
? `${type} is listed by AWS as not supporting resource import`
|
|
77
|
+
: `${type} has no create and read handlers, so CloudFormation cannot import it`;
|
|
78
|
+
}
|
|
79
|
+
if (entry?.tagging?.taggable && entry.tagging.tagUpdatable === false) {
|
|
80
|
+
return `${type}'s tags can only be set at create, so writing chant's markers would replace it`;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4523 on the aws lexicon: never-deployed declarations matched to an
|
|
3
|
+
* indexed fixture by the CloudFormation `primaryIdentifier`. The fixture is
|
|
4
|
+
* the emulator check written out on INTENTIUS/behold#1025's design comment,
|
|
5
|
+
* in miniature: a bucket and a role made outside chant, then a source that
|
|
6
|
+
* declares them, a second bucket that does not exist, a queue and an
|
|
7
|
+
* instance.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { matchDeclaredToIndex, type IndexIdentity } from "@intentius/chant/lifecycle/identity-match";
|
|
13
|
+
import { typeListing, type ListingLexicon, type ListingResult } from "@intentius/chant/lifecycle/type-listing";
|
|
14
|
+
import { awsDeclaredIdentity, awsIndexIdentity, isGlobalAwsType, type IdentityManifestEntry } from "./index-identity";
|
|
15
|
+
import { awsKindForListedType } from "./listed-types";
|
|
16
|
+
import { awsPlugin } from "./plugin";
|
|
17
|
+
|
|
18
|
+
// The manifest rows these types generate (`lexicon-aws.json`): primaryIdentifier
|
|
19
|
+
// and the readOnlyProperties codegen writes as `attrs`, as surface.snapshot.json records them.
|
|
20
|
+
const ROWS: IdentityManifestEntry[] = [
|
|
21
|
+
{ resourceType: "AWS::S3::Bucket", kind: "resource", primaryIdentifier: ["BucketName"], attrs: { Arn: "Arn", DomainName: "DomainName", RegionalDomainName: "RegionalDomainName", WebsiteURL: "WebsiteURL" } },
|
|
22
|
+
{ resourceType: "AWS::IAM::Role", kind: "resource", primaryIdentifier: ["RoleName"], attrs: { Arn: "Arn", RoleId: "RoleId" } },
|
|
23
|
+
{ resourceType: "AWS::SQS::Queue", kind: "resource", primaryIdentifier: ["QueueUrl"], attrs: { Arn: "Arn", QueueUrl: "QueueUrl" } },
|
|
24
|
+
{ resourceType: "AWS::EC2::Instance", kind: "resource", primaryIdentifier: ["InstanceId"], attrs: { InstanceId: "InstanceId", PrivateIp: "PrivateIp", PublicIp: "PublicIp" } },
|
|
25
|
+
{ resourceType: "AWS::DynamoDB::Table", kind: "resource", primaryIdentifier: ["TableName"], attrs: { Arn: "Arn", StreamArn: "StreamArn" } },
|
|
26
|
+
{ resourceType: "AWS::EC2::Route", kind: "resource", primaryIdentifier: ["RouteTableId", "CidrBlock"], attrs: { CidrBlock: "CidrBlock" } },
|
|
27
|
+
];
|
|
28
|
+
const MANIFEST: ReadonlyMap<string, IdentityManifestEntry> = new Map(ROWS.map((e) => [e.resourceType, e]));
|
|
29
|
+
|
|
30
|
+
const identity: IndexIdentity = { ...awsIndexIdentity, declaredIdentity: (t, p) => awsDeclaredIdentity(t, p, MANIFEST) };
|
|
31
|
+
const KINDS: ReadonlySet<string> = new Set(MANIFEST.keys());
|
|
32
|
+
const aws: ListingLexicon = {
|
|
33
|
+
name: awsPlugin.name,
|
|
34
|
+
deepNormalizationHooks: awsPlugin.deepNormalizationHooks,
|
|
35
|
+
listedTypes: { ...awsPlugin.listedTypes!, kindFor: (t) => awsKindForListedType(t, KINDS) },
|
|
36
|
+
};
|
|
37
|
+
const listing = JSON.parse(readFileSync(join(import.meta.dirname, "testdata", "indexed", "identity.listing.json"), "utf-8")) as ListingResult;
|
|
38
|
+
const doc = typeListing(listing, [aws]);
|
|
39
|
+
|
|
40
|
+
const SOURCE = [
|
|
41
|
+
{ name: "assets", entityType: "AWS::S3::Bucket", props: { BucketName: "cli-made-assets" } },
|
|
42
|
+
{ name: "runner", entityType: "AWS::IAM::Role", props: { RoleName: "cli-made-role", AssumeRolePolicyDocument: {} } },
|
|
43
|
+
{ name: "archive", entityType: "AWS::S3::Bucket", props: { BucketName: "never-made-archive" } },
|
|
44
|
+
{ name: "jobs", entityType: "AWS::SQS::Queue", props: { QueueName: "cli-made-queue" } },
|
|
45
|
+
{ name: "box", entityType: "AWS::EC2::Instance", props: { InstanceType: "t3.micro", ImageId: "ami-0abcdef1234567890" } },
|
|
46
|
+
{ name: "carts", entityType: "AWS::DynamoDB::Table", props: { TableName: "carts" } },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
describe("awsDeclaredIdentity", () => {
|
|
50
|
+
test("a declared name is the identifier", () => {
|
|
51
|
+
expect(awsDeclaredIdentity("AWS::S3::Bucket", { BucketName: "b1" }, MANIFEST)).toEqual({ kind: "identifier", listedType: "AWS::S3::Bucket", identifier: "b1", global: false });
|
|
52
|
+
expect(awsDeclaredIdentity("AWS::IAM::Role", { RoleName: "r1" }, MANIFEST)).toMatchObject({ identifier: "r1", global: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a server-assigned identity is refused, naming the read-only part", () => {
|
|
56
|
+
const queue = awsDeclaredIdentity("AWS::SQS::Queue", { QueueName: "q" }, MANIFEST);
|
|
57
|
+
expect(queue).toMatchObject({ kind: "unmatchable", reason: "no-binding" });
|
|
58
|
+
expect(queue.kind === "unmatchable" && queue.detail).toContain("QueueUrl");
|
|
59
|
+
// Even a declaration that spells an instance id cannot be trusted to name the instance.
|
|
60
|
+
expect(awsDeclaredIdentity("AWS::EC2::Instance", { InstanceId: "i-1" }, MANIFEST)).toMatchObject({ kind: "unmatchable", reason: "no-binding" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a part the declaration leaves out, or spells with a Ref, is not in the declaration", () => {
|
|
64
|
+
expect(awsDeclaredIdentity("AWS::S3::Bucket", {}, MANIFEST)).toMatchObject({ reason: "no-binding", detail: expect.stringContaining("identity is not in the declaration") });
|
|
65
|
+
expect(awsDeclaredIdentity("AWS::S3::Bucket", { BucketName: { Ref: "Name" } }, MANIFEST)).toMatchObject({ reason: "no-binding" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an identifier with a server-assigned part is refused even when the rest is declared", () => {
|
|
69
|
+
expect(awsDeclaredIdentity("AWS::EC2::Route", { RouteTableId: "rtb-1", DestinationCidrBlock: "0.0.0.0/0" }, MANIFEST)).toMatchObject({ reason: "no-binding" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a type with no primary identifier on record is unsupported", () => {
|
|
73
|
+
expect(awsDeclaredIdentity("AWS::Made::Up", { Name: "x" }, MANIFEST)).toMatchObject({ kind: "unmatchable", reason: "unsupported-kind" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("global types are the ones behold's lister indexes at location global", () => {
|
|
77
|
+
expect(isGlobalAwsType("AWS::IAM::Role")).toBe(true);
|
|
78
|
+
expect(isGlobalAwsType("AWS::Route53::HostedZone")).toBe(true);
|
|
79
|
+
expect(isGlobalAwsType("AWS::S3::Bucket")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the plugin declares it", () => {
|
|
83
|
+
expect(awsPlugin.indexIdentity?.substrate).toBe("aws");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("matchDeclaredToIndex on the aws fixture", () => {
|
|
88
|
+
const { matches } = matchDeclaredToIndex(SOURCE, [doc], identity, { region: "us-east-1" });
|
|
89
|
+
|
|
90
|
+
test("a never-deployed declared bucket and role bind to their indexed resources", () => {
|
|
91
|
+
expect(matches.assets).toMatchObject({ verdict: "bound", node: "aws/000000000000/us-east-1/AWS::S3::Bucket/cli-made-assets" });
|
|
92
|
+
expect(matches.runner).toMatchObject({ verdict: "bound", node: "aws/000000000000/global/AWS::IAM::Role/cli-made-role" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a bucket the index listed and did not find is absent", () => {
|
|
96
|
+
expect(matches.archive).toMatchObject({ verdict: "absent", identifier: "never-made-archive", location: "us-east-1" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a queue and an instance cannot match from source: no-binding, even though both are indexed", () => {
|
|
100
|
+
expect(matches.jobs).toMatchObject({ verdict: "not-observed", reason: "no-binding" });
|
|
101
|
+
expect(matches.box).toMatchObject({ verdict: "not-observed", reason: "no-binding" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a type outside the index's --types is not observed (filtered), not absent", () => {
|
|
105
|
+
expect(matches.carts).toMatchObject({ verdict: "not-observed", reason: "filtered", listedType: "AWS::DynamoDB::Table" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the same source over an index without the bucket type reports the buckets filtered", () => {
|
|
109
|
+
const narrowed: ListingResult = {
|
|
110
|
+
...listing,
|
|
111
|
+
scope: { ...listing.scope, types: ["AWS::EC2::Instance", "AWS::IAM::Role", "AWS::SQS::Queue"] },
|
|
112
|
+
resources: listing.resources.filter((r) => r.listedType !== "AWS::S3::Bucket"),
|
|
113
|
+
coverage: listing.coverage.filter((c) => c.listedType !== "AWS::S3::Bucket"),
|
|
114
|
+
};
|
|
115
|
+
const { matches: m } = matchDeclaredToIndex(SOURCE, [typeListing(narrowed, [aws])], identity, { region: "us-east-1" });
|
|
116
|
+
expect(m.assets).toMatchObject({ verdict: "not-observed", reason: "filtered" });
|
|
117
|
+
expect(m.archive).toMatchObject({ verdict: "not-observed", reason: "filtered" });
|
|
118
|
+
expect(m.runner).toMatchObject({ verdict: "bound" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("two indexed buckets with the bucket's name are refused by name", () => {
|
|
122
|
+
const twice: ListingResult = {
|
|
123
|
+
...listing,
|
|
124
|
+
scope: { ...listing.scope, locations: ["us-east-1", "us-west-2"] },
|
|
125
|
+
resources: [...listing.resources, { ...listing.resources[0], location: "us-west-2" }],
|
|
126
|
+
coverage: [...listing.coverage, { listedType: "AWS::S3::Bucket", location: "us-west-2", verdict: "listed" }],
|
|
127
|
+
};
|
|
128
|
+
const { matches: m } = matchDeclaredToIndex([SOURCE[0]], [typeListing(twice, [aws])], identity);
|
|
129
|
+
expect(m.assets).toMatchObject({
|
|
130
|
+
verdict: "refused",
|
|
131
|
+
candidates: ["aws/000000000000/us-east-1/AWS::S3::Bucket/cli-made-assets", "aws/000000000000/us-west-2/AWS::S3::Bucket/cli-made-assets"],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aws lexicon's identity rule for indexed resources (#4523, rule written
|
|
3
|
+
* out on INTENTIUS/behold#1025's design comment).
|
|
4
|
+
*
|
|
5
|
+
* The same evidence the stack read's identity fallback uses (#1647,
|
|
6
|
+
* ./identity-observe.ts): the CloudFormation schema's `primaryIdentifier`,
|
|
7
|
+
* which codegen compiled into `lexicon-aws.json`, read off the declared
|
|
8
|
+
* props and `|`-joined the way Cloud Control spells an identifier. behold's
|
|
9
|
+
* lister (behold#1029) writes that Cloud Control `Identifier` as each indexed
|
|
10
|
+
* node's `nativeId`, so a never-deployed bucket declared with `BucketName`
|
|
11
|
+
* and a role declared with `RoleName` match their indexed objects by value.
|
|
12
|
+
*
|
|
13
|
+
* What cannot match:
|
|
14
|
+
* - an identifier part the schema lists in `readOnlyProperties` is assigned
|
|
15
|
+
* by the service (`InstanceId` for `AWS::EC2::Instance`, `QueueUrl` for
|
|
16
|
+
* `AWS::SQS::Queue`), so no declaration states it: `no-binding`;
|
|
17
|
+
* - a part the declaration leaves out or spells with a Ref or an intrinsic:
|
|
18
|
+
* `no-binding`, "identity is not in the declaration";
|
|
19
|
+
* - a type with no primary identifier on record: `unsupported-kind`.
|
|
20
|
+
*
|
|
21
|
+
* Global types are the prefixes behold's aws lister lists once per account at
|
|
22
|
+
* location `global` (behold src/cloud-lister/aws.ts, `isGlobalAwsType`).
|
|
23
|
+
*/
|
|
24
|
+
import { createRequire } from "module";
|
|
25
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
26
|
+
import type { DeclaredIdentity, IndexIdentity } from "@intentius/chant/lifecycle/identity-match";
|
|
27
|
+
|
|
28
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
29
|
+
|
|
30
|
+
/** One `lexicon-aws.json` entry, the fields this rule reads. */
|
|
31
|
+
export interface IdentityManifestEntry {
|
|
32
|
+
resourceType: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
primaryIdentifier?: string[];
|
|
35
|
+
/** GetAtt name -> CloudFormation attribute: the schema's `readOnlyProperties`. */
|
|
36
|
+
attrs?: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const GLOBAL_TYPE_PREFIXES = ["AWS::IAM::", "AWS::CloudFront::", "AWS::Route53::", "AWS::Organizations::", "AWS::GlobalAccelerator::"];
|
|
40
|
+
|
|
41
|
+
/** True for a type behold's lister indexes at location `global`. */
|
|
42
|
+
export function isGlobalAwsType(type: string): boolean {
|
|
43
|
+
return GLOBAL_TYPE_PREFIXES.some((p) => type.startsWith(p));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let cachedManifest: ReadonlyMap<string, IdentityManifestEntry> | undefined;
|
|
47
|
+
|
|
48
|
+
/** The manifest keyed by type, or an empty one in a checkout that has not generated (every type is then `unsupported-kind`). */
|
|
49
|
+
function defaultManifest(): ReadonlyMap<string, IdentityManifestEntry> {
|
|
50
|
+
if (cachedManifest) return cachedManifest;
|
|
51
|
+
try {
|
|
52
|
+
const manifest = require("./generated/lexicon-aws.json") as Record<string, IdentityManifestEntry>;
|
|
53
|
+
cachedManifest = new Map(Object.values(manifest).filter((e) => e.kind === "resource").map((e) => [e.resourceType, e]));
|
|
54
|
+
} catch {
|
|
55
|
+
cachedManifest = new Map();
|
|
56
|
+
}
|
|
57
|
+
return cachedManifest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The identifier an aws declaration spells for the index, or why it spells
|
|
62
|
+
* none. `manifest` is the generated `lexicon-aws.json` keyed by type; a test
|
|
63
|
+
* passes a table.
|
|
64
|
+
*/
|
|
65
|
+
export function awsDeclaredIdentity(
|
|
66
|
+
entityType: string,
|
|
67
|
+
props: Record<string, unknown>,
|
|
68
|
+
manifest: ReadonlyMap<string, IdentityManifestEntry> = defaultManifest(),
|
|
69
|
+
): DeclaredIdentity {
|
|
70
|
+
const entry = manifest.get(entityType);
|
|
71
|
+
const parts = entry?.primaryIdentifier;
|
|
72
|
+
if (!entry || entry.kind !== "resource" || !parts || parts.length === 0) {
|
|
73
|
+
return { kind: "unmatchable", reason: "unsupported-kind", detail: `${entityType} has no primary identifier on record` };
|
|
74
|
+
}
|
|
75
|
+
const readOnly = new Set(Object.values(entry.attrs ?? {}));
|
|
76
|
+
const serverAssigned = parts.filter((p) => readOnly.has(p));
|
|
77
|
+
if (serverAssigned.length > 0) {
|
|
78
|
+
return {
|
|
79
|
+
kind: "unmatchable",
|
|
80
|
+
reason: "no-binding",
|
|
81
|
+
detail: `the identity of ${entityType} (${parts.join(", ")}) is assigned by the service (${serverAssigned.join(", ")}), so no declaration states it`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const values: string[] = [];
|
|
85
|
+
for (const part of parts) {
|
|
86
|
+
const v = props[part];
|
|
87
|
+
if ((typeof v !== "string" && typeof v !== "number") || String(v) === "") {
|
|
88
|
+
return { kind: "unmatchable", reason: "no-binding", detail: `identity is not in the declaration (${entityType} ${part})` };
|
|
89
|
+
}
|
|
90
|
+
values.push(String(v));
|
|
91
|
+
}
|
|
92
|
+
return { kind: "identifier", listedType: entityType, identifier: values.join("|"), global: isGlobalAwsType(entityType) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const awsIndexIdentity: IndexIdentity = {
|
|
96
|
+
substrate: "aws",
|
|
97
|
+
declaredIdentity: (entityType, props) => awsDeclaredIdentity(entityType, props),
|
|
98
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4524 on the aws lexicon: `chant import --index` over a fixture index.
|
|
3
|
+
*
|
|
4
|
+
* The fixture (testdata/indexed/import.listing.json) is a VPC with a subnet
|
|
5
|
+
* and a security group in it, an ECS task definition whose task role is an
|
|
6
|
+
* IAM role, an RDS instance whose master password the lister masked, a
|
|
7
|
+
* bucket, and one object of a type the lexicon does not generate. It is
|
|
8
|
+
* typed (#4521) and its edges derived (#4522, #4523) as `behold index` would
|
|
9
|
+
* write it, then imported through the aws seam and CFGenerator, the codegen
|
|
10
|
+
* `chant import <template>` uses.
|
|
11
|
+
*
|
|
12
|
+
* The emulator round trip (index, import, build, then the declared entities
|
|
13
|
+
* bind to their indexed resources through #4523 with no drift) is a
|
|
14
|
+
* maintainer run; the steps are in the #4524 issue comment.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { describe, expect, test } from "vitest";
|
|
19
|
+
import { build } from "@intentius/chant/build";
|
|
20
|
+
import { MASKED } from "@intentius/chant/deep-observation";
|
|
21
|
+
import { deriveEdges } from "@intentius/chant/lifecycle/derive-edges";
|
|
22
|
+
import { awsDeclaredIdentity, awsIndexIdentity, type IdentityManifestEntry } from "./index-identity";
|
|
23
|
+
import type { IndexDocument } from "@intentius/chant/lifecycle/index-document";
|
|
24
|
+
import { declaredNodes, planIndexImport, type IndexImportSelection } from "@intentius/chant/lifecycle/index-import";
|
|
25
|
+
import { typeListing, type ListingLexicon, type ListingResult } from "@intentius/chant/lifecycle/type-listing";
|
|
26
|
+
import { indexImportReportJson, renderIndexImport } from "@intentius/chant/cli/commands/import-index";
|
|
27
|
+
import type { LexiconPlugin } from "@intentius/chant/lexicon";
|
|
28
|
+
import { awsImportProperties, createAwsIndexImport } from "./index-import";
|
|
29
|
+
import { awsKindForListedType } from "./listed-types";
|
|
30
|
+
import { awsPlugin } from "./plugin";
|
|
31
|
+
import { awsSerializer } from "./serializer";
|
|
32
|
+
|
|
33
|
+
// The registry rows for the fixture's types: primaryIdentifier and the
|
|
34
|
+
// schema's readOnlyProperties, which codegen writes as `attrs`.
|
|
35
|
+
const ROWS: IdentityManifestEntry[] = [
|
|
36
|
+
{ resourceType: "AWS::EC2::VPC", kind: "resource", primaryIdentifier: ["VpcId"], attrs: { VpcId: "VpcId", CidrBlockAssociations: "CidrBlockAssociations", DefaultNetworkAcl: "DefaultNetworkAcl", DefaultSecurityGroup: "DefaultSecurityGroup", Ipv6CidrBlocks: "Ipv6CidrBlocks" } },
|
|
37
|
+
{ resourceType: "AWS::EC2::Subnet", kind: "resource", primaryIdentifier: ["SubnetId"], attrs: { SubnetId: "SubnetId", NetworkAclAssociationId: "NetworkAclAssociationId", Ipv6CidrBlocks: "Ipv6CidrBlocks" } },
|
|
38
|
+
{ resourceType: "AWS::EC2::SecurityGroup", kind: "resource", primaryIdentifier: ["Id"], attrs: { GroupId: "GroupId", Id: "Id" } },
|
|
39
|
+
{ resourceType: "AWS::IAM::Role", kind: "resource", primaryIdentifier: ["RoleName"], attrs: { Arn: "Arn", RoleId: "RoleId" } },
|
|
40
|
+
{ resourceType: "AWS::ECS::TaskDefinition", kind: "resource", primaryIdentifier: ["TaskDefinitionArn"], attrs: { TaskDefinitionArn: "TaskDefinitionArn" } },
|
|
41
|
+
{ resourceType: "AWS::RDS::DBInstance", kind: "resource", primaryIdentifier: ["DBInstanceIdentifier"], attrs: { DBInstanceArn: "DBInstanceArn", DbiResourceId: "DbiResourceId", Endpoint_Address: "Endpoint.Address", Endpoint_Port: "Endpoint.Port" } },
|
|
42
|
+
{ resourceType: "AWS::S3::Bucket", kind: "resource", primaryIdentifier: ["BucketName"], attrs: { Arn: "Arn", DomainName: "DomainName", RegionalDomainName: "RegionalDomainName", WebsiteURL: "WebsiteURL" } },
|
|
43
|
+
];
|
|
44
|
+
const MANIFEST: ReadonlyMap<string, IdentityManifestEntry> = new Map(ROWS.map((e) => [e.resourceType, e]));
|
|
45
|
+
const KINDS: ReadonlySet<string> = new Set(MANIFEST.keys());
|
|
46
|
+
|
|
47
|
+
const listingLexicon: ListingLexicon = {
|
|
48
|
+
name: awsPlugin.name,
|
|
49
|
+
deepNormalizationHooks: awsPlugin.deepNormalizationHooks,
|
|
50
|
+
listedTypes: { ...awsPlugin.listedTypes!, kindFor: (t) => awsKindForListedType(t, KINDS) },
|
|
51
|
+
};
|
|
52
|
+
const identity = { ...awsIndexIdentity, declaredIdentity: (t: string, p: Record<string, unknown>) => awsDeclaredIdentity(t, p, MANIFEST) };
|
|
53
|
+
const importLexicon = { name: "aws", indexImport: createAwsIndexImport(MANIFEST), indexIdentity: identity };
|
|
54
|
+
// renderIndexImport needs the template generator, and the plan needs the seam with the test's registry rows.
|
|
55
|
+
const plugin = { ...awsPlugin, indexImport: importLexicon.indexImport } as LexiconPlugin;
|
|
56
|
+
|
|
57
|
+
const raw = readFileSync(join(import.meta.dirname, "testdata", "indexed", "import.listing.json"), "utf-8");
|
|
58
|
+
|
|
59
|
+
/** The fixture as `behold index` writes it: typed, then derived edges. */
|
|
60
|
+
function indexFrom(listing: ListingResult): IndexDocument {
|
|
61
|
+
return deriveEdges(typeListing(listing, [listingLexicon]), [
|
|
62
|
+
{ name: "aws", referenceCatalog: awsPlugin.referenceCatalog, indexIdentity: identity },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
const doc = indexFrom(JSON.parse(raw) as ListingResult);
|
|
66
|
+
const ALL: IndexImportSelection = { all: true };
|
|
67
|
+
|
|
68
|
+
function importOf(d: IndexDocument, selection: IndexImportSelection, declared?: ReadonlyMap<string, string>) {
|
|
69
|
+
const plan = planIndexImport([d], selection, [importLexicon], declared ? { declared } : {});
|
|
70
|
+
const { files, warnings } = renderIndexImport(plan, [plugin]);
|
|
71
|
+
return { plan, files, warnings };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the generated source and return the synthesized template. */
|
|
75
|
+
async function synth(files: Array<{ path: string; content: string }>): Promise<Record<string, any>> {
|
|
76
|
+
const dir = mkdtempSync(join(import.meta.dirname, "../.index-import-tmp-"));
|
|
77
|
+
try {
|
|
78
|
+
const src = join(dir, "src");
|
|
79
|
+
mkdirSync(src);
|
|
80
|
+
for (const f of files) writeFileSync(join(src, f.path), f.content);
|
|
81
|
+
const result = await build(src, [awsSerializer]);
|
|
82
|
+
expect(result.errors.map((e) => e.message)).toEqual([]);
|
|
83
|
+
return JSON.parse(result.outputs.get("aws") as string);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nodeById = new Map(doc.graph.nodes.map((n) => [n.id, n]));
|
|
90
|
+
|
|
91
|
+
describe("chant import --index on the aws fixture", () => {
|
|
92
|
+
test("the edges the fixture should carry are derived", () => {
|
|
93
|
+
const rules = doc.graph.edges.map((e) => e.rule);
|
|
94
|
+
expect(rules).toContain("aws:AWS::EC2::Subnet:VpcId");
|
|
95
|
+
expect(rules).toContain("aws:AWS::EC2::SecurityGroup:VpcId");
|
|
96
|
+
expect(rules).toContain("aws:AWS::ECS::TaskDefinition:TaskRoleArn");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("round trip: import, then synth, gives each resource the indexed properties", async () => {
|
|
100
|
+
const { plan, files } = importOf(doc, ALL);
|
|
101
|
+
expect(files.map((f) => f.path)).toEqual(["main.ts"]);
|
|
102
|
+
const template = await synth(files);
|
|
103
|
+
|
|
104
|
+
const imported = plan.report.resources.filter((r) => r.status === "imported");
|
|
105
|
+
expect(imported.map((r) => r.identity.listedType).sort()).toEqual([
|
|
106
|
+
"AWS::EC2::SecurityGroup",
|
|
107
|
+
"AWS::EC2::Subnet",
|
|
108
|
+
"AWS::EC2::VPC",
|
|
109
|
+
"AWS::ECS::TaskDefinition",
|
|
110
|
+
"AWS::IAM::Role",
|
|
111
|
+
"AWS::RDS::DBInstance",
|
|
112
|
+
"AWS::S3::Bucket",
|
|
113
|
+
]);
|
|
114
|
+
const byLogical = new Map(imported.map((r) => [r.logicalId!, nodeById.get(r.node)!]));
|
|
115
|
+
const params = new Set(plan.report.parameters.map((p) => p.name));
|
|
116
|
+
|
|
117
|
+
// Each reference and parameter read back as the value it stands for.
|
|
118
|
+
const resolveBack = (v: unknown): unknown => {
|
|
119
|
+
if (Array.isArray(v)) return v.map(resolveBack);
|
|
120
|
+
if (v && typeof v === "object") {
|
|
121
|
+
const o = v as Record<string, unknown>;
|
|
122
|
+
if (typeof o.Ref === "string" && Object.keys(o).length === 1) {
|
|
123
|
+
if (params.has(o.Ref)) return MASKED;
|
|
124
|
+
const n = byLogical.get(o.Ref);
|
|
125
|
+
if (n) return n.nativeId;
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(o["Fn::GetAtt"]) && Object.keys(o).length === 1) {
|
|
128
|
+
const [id, attr] = o["Fn::GetAtt"] as [string, string];
|
|
129
|
+
const n = byLogical.get(id);
|
|
130
|
+
if (n && attr === "Arn") return n.facts?.cloudIds?.find((c) => c.kind === "arn")?.value;
|
|
131
|
+
}
|
|
132
|
+
return Object.fromEntries(Object.entries(o).map(([k, x]) => [k, resolveBack(x)]));
|
|
133
|
+
}
|
|
134
|
+
return v;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const [logicalId, node] of byLogical) {
|
|
138
|
+
const built = template.Resources[logicalId];
|
|
139
|
+
expect(built.Type).toBe(node.kind);
|
|
140
|
+
const expected = awsImportProperties(node.kind, node.attrs, MANIFEST);
|
|
141
|
+
expect(resolveBack(built.Properties ?? {})).toEqual(expected);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("every imported resource builds with DeletionPolicy and UpdateReplacePolicy Retain (#4536)", async () => {
|
|
146
|
+
const { plan, files } = importOf(doc, ALL);
|
|
147
|
+
const template = await synth(files);
|
|
148
|
+
for (const r of plan.report.resources.filter((x) => x.status === "imported")) {
|
|
149
|
+
expect(template.Resources[r.logicalId!]).toMatchObject({ DeletionPolicy: "Retain", UpdateReplacePolicy: "Retain" });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("the same index and selection give byte-identical files, in any listing order", () => {
|
|
154
|
+
const first = importOf(doc, ALL);
|
|
155
|
+
const shuffled = JSON.parse(raw) as ListingResult;
|
|
156
|
+
shuffled.resources = [...shuffled.resources].reverse();
|
|
157
|
+
const again = importOf(indexFrom(shuffled), ALL);
|
|
158
|
+
expect(again.files).toEqual(first.files);
|
|
159
|
+
expect(indexImportReportJson(again.plan.report)).toBe(indexImportReportJson(first.plan.report));
|
|
160
|
+
expect(importOf(doc, ALL).files).toEqual(first.files);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a masked secret is written as a NoEcho parameter, never as a value", async () => {
|
|
164
|
+
const { plan, files } = importOf(doc, { types: ["AWS::RDS::DBInstance"] });
|
|
165
|
+
const source = files[0].content;
|
|
166
|
+
expect(source).not.toContain(MASKED);
|
|
167
|
+
expect(plan.report.parameters).toHaveLength(1);
|
|
168
|
+
const param = plan.report.parameters[0];
|
|
169
|
+
expect(param.path).toBe("MasterUserPassword");
|
|
170
|
+
expect(source).toContain(`fill in the parameter ${param.name}`);
|
|
171
|
+
const template = await synth(files);
|
|
172
|
+
expect(template.Parameters[param.name]).toMatchObject({ Type: "String", NoEcho: true });
|
|
173
|
+
const db = plan.report.resources.find((r) => r.status === "imported")!;
|
|
174
|
+
expect(template.Resources[db.logicalId!].Properties.MasterUserPassword).toEqual({ Ref: param.name });
|
|
175
|
+
expect(JSON.stringify(template)).not.toContain(MASKED);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("derived edges become Ref and GetAtt between imported resources", async () => {
|
|
179
|
+
const { plan, files } = importOf(doc, ALL);
|
|
180
|
+
const template = await synth(files);
|
|
181
|
+
const logical = (type: string) => plan.report.resources.find((r) => r.identity.listedType === type)!.logicalId!;
|
|
182
|
+
const vpc = logical("AWS::EC2::VPC");
|
|
183
|
+
expect(template.Resources[logical("AWS::EC2::Subnet")].Properties.VpcId).toEqual({ Ref: vpc });
|
|
184
|
+
expect(template.Resources[logical("AWS::EC2::SecurityGroup")].Properties.VpcId).toEqual({ Ref: vpc });
|
|
185
|
+
expect(template.Resources[logical("AWS::ECS::TaskDefinition")].Properties.TaskRoleArn).toEqual({
|
|
186
|
+
"Fn::GetAtt": [logical("AWS::IAM::Role"), "Arn"],
|
|
187
|
+
});
|
|
188
|
+
const subnet = plan.report.resources.find((r) => r.identity.listedType === "AWS::EC2::Subnet")!;
|
|
189
|
+
expect(subnet.references).toContainEqual({
|
|
190
|
+
path: "VpcId",
|
|
191
|
+
target: "aws/000000000000/us-east-1/AWS::EC2::VPC/vpc-0imp",
|
|
192
|
+
rule: "aws:AWS::EC2::Subnet:VpcId",
|
|
193
|
+
written: "reference",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("an edge to a resource outside the selection stays a literal, with a comment naming the edge", () => {
|
|
198
|
+
const { plan, files } = importOf(doc, { types: ["AWS::EC2::Subnet"] });
|
|
199
|
+
const source = files[0].content;
|
|
200
|
+
expect(source).toContain('"vpc-0imp"');
|
|
201
|
+
expect(source).toMatch(/VpcId = "vpc-0imp" stays a literal: derived edge aws:AWS::EC2::Subnet:VpcId to aws\/000000000000\/us-east-1\/AWS::EC2::VPC\/vpc-0imp; the target is not in the selection/);
|
|
202
|
+
const subnet = plan.report.resources[0];
|
|
203
|
+
expect(subnet.references?.[0]).toMatchObject({ path: "VpcId", written: "literal", reason: "the target is not in the selection" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("aws: tags and read-only properties are left out", () => {
|
|
207
|
+
const vpc = doc.graph.nodes.find((n) => n.kind === "AWS::EC2::VPC")!;
|
|
208
|
+
const props = awsImportProperties(vpc.kind, vpc.attrs, MANIFEST);
|
|
209
|
+
expect(props.VpcId).toBeUndefined();
|
|
210
|
+
expect(props.Tags).toEqual([{ Key: "Name", Value: "shop" }]);
|
|
211
|
+
const db = doc.graph.nodes.find((n) => n.kind === "AWS::RDS::DBInstance")!;
|
|
212
|
+
expect(awsImportProperties(db.kind, db.attrs, MANIFEST).Endpoint).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a declared bucket is reported as declared, not imported again", () => {
|
|
216
|
+
const declared = declaredNodes(
|
|
217
|
+
[doc],
|
|
218
|
+
new Map([["aws", [{ name: "assets", entityType: "AWS::S3::Bucket", props: { BucketName: "imp-assets" } }]]]),
|
|
219
|
+
[importLexicon],
|
|
220
|
+
);
|
|
221
|
+
const { plan, files } = importOf(doc, ALL, declared);
|
|
222
|
+
const bucket = plan.report.resources.find((r) => r.identity.listedType === "AWS::S3::Bucket")!;
|
|
223
|
+
expect(bucket).toMatchObject({ status: "declared", entity: "assets" });
|
|
224
|
+
expect(files[0].content).not.toContain("imp-assets");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("an unmapped type is named as unsupported, with its identity and cloud ids in the report", () => {
|
|
228
|
+
const { plan } = importOf(doc, ALL);
|
|
229
|
+
const other = plan.report.resources.find((r) => r.identity.listedType === "AWS::Example::NotGenerated")!;
|
|
230
|
+
expect(other.status).toBe("unsupported");
|
|
231
|
+
expect(other.reason).toMatch(/no lexicon kind maps/);
|
|
232
|
+
const role = plan.report.resources.find((r) => r.identity.listedType === "AWS::IAM::Role")!;
|
|
233
|
+
expect(role.identity).toEqual({ substrate: "aws", scopeId: "000000000000", listedType: "AWS::IAM::Role", location: "global", nativeId: "imp-task-role" });
|
|
234
|
+
expect(role.cloudIds).toContainEqual({ kind: "arn", value: "arn:aws:iam::000000000000:role/imp-task-role" });
|
|
235
|
+
// The global role joins the only region, so one file holds everything.
|
|
236
|
+
expect(plan.groups.map((g) => g.dir)).toEqual([""]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the plugin declares the seam", () => {
|
|
240
|
+
expect(awsPlugin.indexImport).toBeDefined();
|
|
241
|
+
});
|
|
242
|
+
});
|