@intentius/chant-lexicon-aws 0.124.43 → 0.124.44
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 -6
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +6 -2
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +17 -7
- package/dist/import/live-export.d.ts.map +1 -1
- package/dist/import/live-export.js +8 -4
- package/dist/integrity.json +5 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +1 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/audit-lineage.js +4 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/lint/post-synth/waw017.d.ts.map +1 -1
- package/dist/lint/post-synth/waw017.js +3 -2
- package/dist/lint/post-synth/waw071.d.ts +19 -0
- package/dist/lint/post-synth/waw071.d.ts.map +1 -0
- package/dist/lint/post-synth/waw071.js +76 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +3618 -1206
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/WAW071.md +19 -0
- package/dist/okf/types/Key.md +1 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/SsmParameter.md +4 -0
- package/dist/okf/types/Type.md +1 -0
- package/dist/okf/types/UserPool.md +1 -0
- package/dist/rules/waw017.ts +3 -2
- package/dist/rules/waw071.ts +85 -0
- package/dist/serializer.d.ts +12 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +44 -10
- package/dist/spec/parse.d.ts +27 -5
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/parse.js +56 -0
- package/dist/taggable.d.ts +28 -0
- package/dist/taggable.d.ts.map +1 -1
- package/dist/taggable.js +60 -9
- package/package.json +2 -2
- package/src/codegen/generate-lexicon.ts +2 -2
- package/src/deep-observe.test.ts +19 -0
- package/src/deep-observe.ts +17 -6
- package/src/generated/lexicon-aws.json +3618 -1206
- package/src/import/live-export.test.ts +18 -0
- package/src/import/live-export.ts +8 -4
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/audit-lineage.ts +4 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/waw017.ts +3 -2
- package/src/lint/post-synth/waw071.test.ts +50 -0
- package/src/lint/post-synth/waw071.ts +85 -0
- package/src/serializer-tag-shapes.test.ts +83 -0
- package/src/serializer.ts +46 -9
- package/src/spec/parse.test.ts +39 -0
- package/src/spec/parse.ts +83 -1
- package/src/taggable.ts +78 -11
|
@@ -66,6 +66,24 @@ describe("AWS exportResources mapping (#115)", () => {
|
|
|
66
66
|
expect(ir.resources.map((r) => r.logicalId)).toEqual(["Mine"]);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test("owned filter reads map tags, UserPoolTags and the Metadata marker (#4350)", () => {
|
|
70
|
+
const tpl = {
|
|
71
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
72
|
+
Resources: {
|
|
73
|
+
Param: { Type: "AWS::SSM::Parameter", Properties: { Type: "String", Value: "v", Tags: { "chant:managed-by": "chant" } } },
|
|
74
|
+
Pool: { Type: "AWS::Cognito::UserPool", Properties: { UserPoolTags: { "chant:managed-by": "chant" } } },
|
|
75
|
+
Reservation: {
|
|
76
|
+
Type: "AWS::EC2::CapacityReservation",
|
|
77
|
+
Metadata: { "chant:ownership": { "chant:managed-by": "chant" } },
|
|
78
|
+
Properties: { InstanceType: "t3.micro" },
|
|
79
|
+
},
|
|
80
|
+
Theirs: { Type: "AWS::SSM::Parameter", Properties: { Type: "String", Value: "v", Tags: { team: "other" } } },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const ir = parseStackTemplate(tpl, undefined, true);
|
|
84
|
+
expect(ir.resources.map((r) => r.logicalId)).toEqual(["Param", "Pool", "Reservation"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
69
87
|
test("export IR feeds CFGenerator (templateGenerator) unchanged", () => {
|
|
70
88
|
const ir = parseStackTemplate(liveTemplate);
|
|
71
89
|
const files = new CFGenerator().generate(ir);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
2
|
-
import { hasOwnershipMarker
|
|
3
|
-
import { AWS_TAG_OWNERSHIP_KEYS } from "../ownership";
|
|
2
|
+
import { hasOwnershipMarker } from "@intentius/chant/ownership";
|
|
3
|
+
import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "../ownership";
|
|
4
|
+
import { readResourceTags, readTagValue } from "../taggable";
|
|
4
5
|
import { CFParser } from "./parser";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -30,8 +31,11 @@ export function parseStackTemplate(
|
|
|
30
31
|
if (selector?.type !== undefined && r.type !== selector.type) return false;
|
|
31
32
|
if (selector?.name !== undefined && r.logicalId !== selector.name) return false;
|
|
32
33
|
if (owned) {
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// The marker sits in the type's own tag property, in either shape, or
|
|
35
|
+
// in the resource's Metadata for a type whose tags can't be stamped (#4350).
|
|
36
|
+
const tags = readResourceTags(r.type, r.properties as Record<string, unknown>);
|
|
37
|
+
const marker = readTagValue(r.metadata?.[OWNERSHIP_METADATA_KEY]);
|
|
38
|
+
if (!hasOwnershipMarker(tags, AWS_TAG_OWNERSHIP_KEYS) && !hasOwnershipMarker(marker, AWS_TAG_OWNERSHIP_KEYS)) return false;
|
|
35
39
|
}
|
|
36
40
|
return true;
|
|
37
41
|
}),
|
|
@@ -91,6 +91,7 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
|
|
|
91
91
|
WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
|
|
92
92
|
WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
|
|
93
93
|
WAW070: auditRule("WAW070", "merge-worthy", "guidance", "Ref or Fn::GetAtt names something the template doesn't declare", "Reference a resource or parameter in this template. A value from another lexicon's resource arrives as a parameter.", { category: "correctness" }),
|
|
94
|
+
WAW071: auditRule("WAW071", "merge-worthy", "guidance", "Tags are in the wrong property or the wrong shape for the resource type", "Write the tags in the property the type uses and in the shape it takes, which is a list of Key and Value pairs for most types and a map for some, such as UserPoolTags on AWS::Cognito::UserPool.", { category: "correctness" }),
|
|
94
95
|
};
|
|
95
96
|
|
|
96
97
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
@@ -160,4 +160,8 @@ export const awsAuditLineage: Record<string, Lineage[]> = {
|
|
|
160
160
|
{ tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
|
|
161
161
|
{ tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
|
|
162
162
|
],
|
|
163
|
+
WAW071: [
|
|
164
|
+
{ tool: "cfn-lint", rule: "E3002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E3002", relation: "overlaps" },
|
|
165
|
+
{ tool: "cfn-lint", rule: "E3012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E3012", relation: "overlaps" },
|
|
166
|
+
],
|
|
163
167
|
};
|
|
@@ -62,6 +62,7 @@ import { waw067 } from "./waw067";
|
|
|
62
62
|
import { waw068 } from "./waw068";
|
|
63
63
|
import { waw069 } from "./waw069";
|
|
64
64
|
import { waw070 } from "./waw070";
|
|
65
|
+
import { waw071 } from "./waw071";
|
|
65
66
|
|
|
66
67
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
67
68
|
cor020,
|
|
@@ -126,4 +127,5 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
126
127
|
waw068,
|
|
127
128
|
waw069,
|
|
128
129
|
waw070,
|
|
130
|
+
waw071,
|
|
129
131
|
];
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
9
|
import { parseCFTemplate } from "./cf-refs";
|
|
10
|
-
import { loadTaggableResources } from "../../taggable";
|
|
10
|
+
import { loadTaggableResources, tagPlacementFor } from "../../taggable";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Core detection logic — exported for direct testing with synthetic data.
|
|
@@ -28,7 +28,8 @@ export function checkMissingTags(
|
|
|
28
28
|
if (!taggable.has(resource.Type)) continue;
|
|
29
29
|
|
|
30
30
|
const props = resource.Properties ?? {};
|
|
31
|
-
|
|
31
|
+
const tagProperty = tagPlacementFor(resource.Type)?.property ?? "Tags";
|
|
32
|
+
if (!(tagProperty in props)) {
|
|
32
33
|
diagnostics.push({
|
|
33
34
|
checkId: "WAW017",
|
|
34
35
|
severity: "warning",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw071, checkTagShapes } from "./waw071";
|
|
4
|
+
import type { TagPlacement } from "../../taggable";
|
|
5
|
+
|
|
6
|
+
const placements = new Map<string, TagPlacement | null>([
|
|
7
|
+
["AWS::S3::Bucket", { property: "Tags", shape: "list" }],
|
|
8
|
+
["AWS::SSM::Parameter", { property: "Tags", shape: "map" }],
|
|
9
|
+
["AWS::Cognito::UserPool", { property: "UserPoolTags", shape: "map" }],
|
|
10
|
+
["AWS::EC2::CapacityReservation", null],
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function check(resources: Record<string, unknown>) {
|
|
14
|
+
return checkTagShapes(createPostSynthContext({ aws: { Resources: resources } }), placements);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("WAW071: tags in the wrong property or shape", () => {
|
|
18
|
+
test("check metadata", () => {
|
|
19
|
+
expect(waw071.id).toBe("WAW071");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("flags Tags on a user pool", () => {
|
|
23
|
+
const d = check({ Pool: { Type: "AWS::Cognito::UserPool", Properties: { Tags: [{ Key: "a", Value: "b" }] } } });
|
|
24
|
+
expect(d).toHaveLength(1);
|
|
25
|
+
expect(d[0].message).toContain("UserPoolTags");
|
|
26
|
+
expect(d[0].severity).toBe("error");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("flags a list on a map type", () => {
|
|
30
|
+
const d = check({ P: { Type: "AWS::SSM::Parameter", Properties: { Tags: [{ Key: "a", Value: "b" }] } } });
|
|
31
|
+
expect(d).toHaveLength(1);
|
|
32
|
+
expect(d[0].message).toContain("map");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("flags a map on a list type", () => {
|
|
36
|
+
const d = check({ B: { Type: "AWS::S3::Bucket", Properties: { Tags: { a: "b" } } } });
|
|
37
|
+
expect(d).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("passes the right shapes, intrinsics and unplaceable types", () => {
|
|
41
|
+
const d = check({
|
|
42
|
+
B: { Type: "AWS::S3::Bucket", Properties: { Tags: [{ Key: "a", Value: { Ref: "X" } }, { "Fn::If": ["C", { Key: "k", Value: "v" }, { Ref: "AWS::NoValue" }] }] } },
|
|
43
|
+
P: { Type: "AWS::SSM::Parameter", Properties: { Tags: { a: "b" } } },
|
|
44
|
+
Pool: { Type: "AWS::Cognito::UserPool", Properties: { UserPoolTags: { a: "b" } } },
|
|
45
|
+
Q: { Type: "AWS::SSM::Parameter", Properties: { Tags: { "Fn::If": ["C", { a: "b" }, {}] } } },
|
|
46
|
+
CR: { Type: "AWS::EC2::CapacityReservation", Properties: { Tags: [] } },
|
|
47
|
+
});
|
|
48
|
+
expect(d).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW071: Tags are in the wrong property or the wrong shape for the type
|
|
3
|
+
*
|
|
4
|
+
* Each taggable type names its tag property and shape in its schema
|
|
5
|
+
* (`tagging.tagProperty`): a `[{Key, Value}]` list for most types, a map for
|
|
6
|
+
* types such as `AWS::SSM::Parameter`, and a property other than `Tags` for
|
|
7
|
+
* types such as `AWS::Cognito::UserPool` (`UserPoolTags`). CloudFormation
|
|
8
|
+
* rejects a template that puts tags anywhere else. chant stamped the ownership
|
|
9
|
+
* marker as a `Tags` list on every type until #4350; this check catches the
|
|
10
|
+
* next such template.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
14
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
15
|
+
import { isTagIntrinsic, loadTagPlacements, type TagPlacement } from "../../taggable";
|
|
16
|
+
|
|
17
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Why `value` doesn't fit `placement`, or undefined when it does. */
|
|
22
|
+
function shapeProblem(value: unknown, placement: TagPlacement): string | undefined {
|
|
23
|
+
if (isTagIntrinsic(value)) return undefined;
|
|
24
|
+
if (placement.shape === "map") {
|
|
25
|
+
return isPlainObject(value) ? undefined : "a list, but the type takes a key-to-value map";
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(value)) return "a map, but the type takes a [{Key, Value}] list";
|
|
28
|
+
const bad = value.some((t) => !isTagIntrinsic(t) && !(isPlainObject(t) && "Key" in t && "Value" in t));
|
|
29
|
+
return bad ? "a list with an entry that has no Key and Value" : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Core detection logic — exported for direct testing with synthetic data.
|
|
34
|
+
*/
|
|
35
|
+
export function checkTagShapes(
|
|
36
|
+
ctx: PostSynthContext,
|
|
37
|
+
placements: Map<string, TagPlacement | null>,
|
|
38
|
+
): PostSynthDiagnostic[] {
|
|
39
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
40
|
+
|
|
41
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
42
|
+
if (lexicon !== "aws") continue;
|
|
43
|
+
const template = parseCFTemplate(output);
|
|
44
|
+
if (!template?.Resources) continue;
|
|
45
|
+
|
|
46
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
47
|
+
const placement = placements.get(resource.Type);
|
|
48
|
+
if (!placement) continue;
|
|
49
|
+
const props = resource.Properties ?? {};
|
|
50
|
+
|
|
51
|
+
if (placement.property !== "Tags" && "Tags" in props) {
|
|
52
|
+
diagnostics.push({
|
|
53
|
+
checkId: "WAW071",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: `Resource "${logicalId}" (${resource.Type}) has Tags, but the type takes its tags in ${placement.property}`,
|
|
56
|
+
entity: logicalId,
|
|
57
|
+
lexicon: "aws",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!(placement.property in props)) continue;
|
|
62
|
+
const problem = shapeProblem(props[placement.property], placement);
|
|
63
|
+
if (problem) {
|
|
64
|
+
diagnostics.push({
|
|
65
|
+
checkId: "WAW071",
|
|
66
|
+
severity: "error",
|
|
67
|
+
message: `Resource "${logicalId}" (${resource.Type}) has ${placement.property} as ${problem}`,
|
|
68
|
+
entity: logicalId,
|
|
69
|
+
lexicon: "aws",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return diagnostics;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const waw071: PostSynthCheck = {
|
|
79
|
+
id: "WAW071",
|
|
80
|
+
description: "Tags are in the wrong property or the wrong shape for the resource type",
|
|
81
|
+
|
|
82
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
83
|
+
return checkTagShapes(ctx, loadTagPlacements());
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { awsSerializer, mergeTags } from "./serializer";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { loadTagPlacements } from "./taggable";
|
|
5
|
+
|
|
6
|
+
class MockResource implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "aws";
|
|
9
|
+
constructor(readonly entityType: string, readonly props: Record<string, unknown> = {}) {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const OWNERSHIP = { stack: "loom", env: "dev" };
|
|
13
|
+
const MARKER = { "chant:managed-by": "chant", "chant:stack": "loom", "chant:env": "dev" };
|
|
14
|
+
|
|
15
|
+
function build(type: string, props: Record<string, unknown> = {}) {
|
|
16
|
+
const entities = new Map<string, Declarable>([["R", new MockResource(type, props)]]);
|
|
17
|
+
const template = JSON.parse(awsSerializer.serialize(entities, [], { ownership: OWNERSHIP }) as string);
|
|
18
|
+
return template.Resources.R as { Properties?: Record<string, unknown>; Metadata?: Record<string, unknown> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("tag placements from the generated lexicon (#4350)", () => {
|
|
22
|
+
test("reads property and shape per type", () => {
|
|
23
|
+
const p = loadTagPlacements();
|
|
24
|
+
expect(p.get("AWS::S3::Bucket")).toEqual({ property: "Tags", shape: "list" });
|
|
25
|
+
expect(p.get("AWS::SSM::Parameter")).toEqual({ property: "Tags", shape: "map" });
|
|
26
|
+
expect(p.get("AWS::BedrockAgentCore::Runtime")).toEqual({ property: "Tags", shape: "map" });
|
|
27
|
+
expect(p.get("AWS::BedrockAgentCore::Memory")).toEqual({ property: "Tags", shape: "map" });
|
|
28
|
+
expect(p.get("AWS::Cognito::UserPool")).toEqual({ property: "UserPoolTags", shape: "map" });
|
|
29
|
+
expect(p.get("AWS::EC2::CapacityReservation")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("ownership stamping by tag shape (#4350)", () => {
|
|
34
|
+
test("list type gets a [{Key, Value}] list in Tags", () => {
|
|
35
|
+
const r = build("AWS::S3::Bucket", { BucketName: "b" });
|
|
36
|
+
expect(r.Properties!.Tags).toEqual(Object.entries(MARKER).map(([Key, Value]) => ({ Key, Value })));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("map type gets a map in Tags", () => {
|
|
40
|
+
const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v" });
|
|
41
|
+
expect(r.Properties!.Tags).toEqual(MARKER);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("Cognito user pool gets UserPoolTags, and no Tags", () => {
|
|
45
|
+
const r = build("AWS::Cognito::UserPool", { UserPoolName: "p", UserPoolTags: { component: "loom-cognito" } });
|
|
46
|
+
expect(r.Properties!.Tags).toBeUndefined();
|
|
47
|
+
expect(r.Properties!.UserPoolTags).toEqual({ ...MARKER, component: "loom-cognito" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("explicit map tags merge on a map type, explicit key wins", () => {
|
|
51
|
+
const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v", Tags: { team: "a", "chant:env": "override" } });
|
|
52
|
+
expect(r.Properties!.Tags).toEqual({ ...MARKER, team: "a", "chant:env": "override" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("explicit list tags on a map type are converted", () => {
|
|
56
|
+
const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v", Tags: [{ Key: "team", Value: "a" }] });
|
|
57
|
+
expect(r.Properties!.Tags).toEqual({ ...MARKER, team: "a" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("explicit map tags on a list type are converted", () => {
|
|
61
|
+
const r = build("AWS::S3::Bucket", { Tags: { team: "a" } });
|
|
62
|
+
expect(r.Properties!.Tags).toContainEqual({ Key: "team", Value: "a" });
|
|
63
|
+
expect(r.Properties!.Tags).toContainEqual({ Key: "chant:managed-by", Value: "chant" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a type it can't place gets the marker in its own Metadata, not a Tags property", () => {
|
|
67
|
+
const r = build("AWS::EC2::CapacityReservation", { InstanceType: "t3.micro" });
|
|
68
|
+
expect(r.Properties!.Tags).toBeUndefined();
|
|
69
|
+
expect(r.Metadata!["chant:ownership"]).toEqual(MARKER);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("mergeTags", () => {
|
|
74
|
+
const stamped = [{ Key: "chant:managed-by", Value: "chant" }];
|
|
75
|
+
test("an intrinsic explicit value can't be merged", () => {
|
|
76
|
+
expect(mergeTags({ "Fn::If": ["C", [], []] }, stamped, { property: "Tags", shape: "list" })).toBeUndefined();
|
|
77
|
+
expect(mergeTags({ Ref: "T" }, stamped, { property: "Tags", shape: "map" })).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
test("no explicit value stamps alone", () => {
|
|
80
|
+
expect(mergeTags(undefined, stamped, { property: "Tags", shape: "map" })).toEqual({ "chant:managed-by": "chant" });
|
|
81
|
+
expect(mergeTags(undefined, stamped, { property: "Tags", shape: "list" })).toEqual(stamped);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { isMapping } from "./mapping";
|
|
|
26
26
|
import { isDefaultTags, type TagEntry } from "./default-tags";
|
|
27
27
|
import { isTemplateTransform } from "./template-transform";
|
|
28
28
|
import { isTemplateSection } from "./template-sections";
|
|
29
|
-
import {
|
|
29
|
+
import { isTagIntrinsic, loadTagPlacements, readTagValue, type TagPlacement } from "./taggable";
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Check if a declarable is a CoreParameter
|
|
@@ -292,6 +292,33 @@ function renderReceiptRows(
|
|
|
292
292
|
return rows;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Merge stamped default tags under a resource's explicit tags, in the type's
|
|
297
|
+
* tag shape (#4350). Explicit tags win on a key clash. Explicit tags of the
|
|
298
|
+
* other shape are converted, so a map written on a list type still merges.
|
|
299
|
+
* Returns undefined when the explicit value is an intrinsic, which can't be
|
|
300
|
+
* merged into.
|
|
301
|
+
*/
|
|
302
|
+
export function mergeTags(
|
|
303
|
+
explicit: unknown,
|
|
304
|
+
stamped: Array<{ Key: string; Value: unknown }>,
|
|
305
|
+
placement: TagPlacement,
|
|
306
|
+
): unknown {
|
|
307
|
+
if (explicit !== undefined && (isTagIntrinsic(explicit) || (typeof explicit !== "object" || explicit === null))) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
if (placement.shape === "map") {
|
|
311
|
+
const merged: Record<string, unknown> = {};
|
|
312
|
+
for (const t of stamped) merged[t.Key] = t.Value;
|
|
313
|
+
return { ...merged, ...readTagValue(explicit) };
|
|
314
|
+
}
|
|
315
|
+
const explicitList = Array.isArray(explicit)
|
|
316
|
+
? (explicit as Array<{ Key?: unknown }>)
|
|
317
|
+
: Object.entries(readTagValue(explicit)).map(([Key, Value]) => ({ Key, Value }));
|
|
318
|
+
const explicitKeys = new Set(explicitList.map(t => t?.Key));
|
|
319
|
+
return [...stamped.filter(t => !explicitKeys.has(t.Key)), ...explicitList];
|
|
320
|
+
}
|
|
321
|
+
|
|
295
322
|
/**
|
|
296
323
|
* Serialize a set of entities into a CFTemplate object (without JSON.stringify).
|
|
297
324
|
*/
|
|
@@ -510,20 +537,30 @@ function serializeToTemplate(
|
|
|
510
537
|
}
|
|
511
538
|
}
|
|
512
539
|
|
|
513
|
-
// Inject default tags into taggable resources
|
|
540
|
+
// Inject default tags into taggable resources, each through its own tag
|
|
541
|
+
// property and shape (#4350): `UserPoolTags` on a Cognito user pool, a map on
|
|
542
|
+
// an SSM parameter, a `[{Key, Value}]` list elsewhere.
|
|
514
543
|
if (defaultTagEntries.length > 0) {
|
|
515
|
-
const
|
|
544
|
+
const placements = loadTagPlacements();
|
|
516
545
|
for (const [, resource] of Object.entries(template.Resources)) {
|
|
517
|
-
|
|
546
|
+
const placement = placements.get(resource.Type);
|
|
547
|
+
if (placement === undefined) continue;
|
|
518
548
|
const resolved = defaultTagEntries.map(t => ({
|
|
519
549
|
Key: t.Key,
|
|
520
550
|
Value: toCFValue(t.Value, entityNames),
|
|
521
551
|
}));
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
552
|
+
const stamped = placement ? mergeTags(resource.Properties?.[placement.property], resolved, placement) : undefined;
|
|
553
|
+
if (stamped !== undefined) {
|
|
554
|
+
if (!resource.Properties) resource.Properties = {};
|
|
555
|
+
resource.Properties[placement!.property] = stamped;
|
|
556
|
+
} else if (ownership) {
|
|
557
|
+
// Nowhere valid to write the tags, so the marker goes on the resource's
|
|
558
|
+
// own Metadata instead of an invalid property.
|
|
559
|
+
resource.Metadata = {
|
|
560
|
+
...(resource.Metadata ?? {}),
|
|
561
|
+
[OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
527
564
|
}
|
|
528
565
|
}
|
|
529
566
|
|
package/src/spec/parse.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
cfnShortName,
|
|
5
5
|
cfnServiceName,
|
|
6
6
|
stripPointerPath,
|
|
7
|
+
tagPlacement,
|
|
7
8
|
} from "./parse";
|
|
8
9
|
import type { CFNSchema } from "./fetch";
|
|
9
10
|
|
|
@@ -496,3 +497,41 @@ describe("cfnServiceName", () => {
|
|
|
496
497
|
expect(cfnServiceName("AWS::IAM::Role")).toBe("IAM");
|
|
497
498
|
});
|
|
498
499
|
});
|
|
500
|
+
|
|
501
|
+
describe("tagPlacement (#4350)", () => {
|
|
502
|
+
const base = { typeName: "AWS::Test::T", additionalProperties: false };
|
|
503
|
+
test("a Key/Value list in Tags", () => {
|
|
504
|
+
expect(tagPlacement({
|
|
505
|
+
...base,
|
|
506
|
+
properties: { Tags: { type: "array", items: { $ref: "#/definitions/Tag" } } },
|
|
507
|
+
definitions: { Tag: { type: "object", properties: { Key: { type: "string" }, Value: { type: "string" } } } },
|
|
508
|
+
tagging: { taggable: true, tagProperty: "/properties/Tags" },
|
|
509
|
+
} as CFNSchema)).toEqual({ tagProperty: "Tags", tagShape: "list" });
|
|
510
|
+
});
|
|
511
|
+
test("a map under another property name", () => {
|
|
512
|
+
expect(tagPlacement({
|
|
513
|
+
...base,
|
|
514
|
+
properties: { UserPoolTags: { type: "object", patternProperties: { ".*": { type: "string" } } } as never },
|
|
515
|
+
tagging: { taggable: true, tagProperty: "/properties/UserPoolTags" },
|
|
516
|
+
} as CFNSchema)).toEqual({ tagProperty: "UserPoolTags", tagShape: "map" });
|
|
517
|
+
});
|
|
518
|
+
test("Tags when tagProperty is absent", () => {
|
|
519
|
+
expect(tagPlacement({
|
|
520
|
+
...base,
|
|
521
|
+
properties: { Tags: { type: "object" } },
|
|
522
|
+
tagging: { taggable: true },
|
|
523
|
+
} as CFNSchema)).toEqual({ tagProperty: "Tags", tagShape: "map" });
|
|
524
|
+
});
|
|
525
|
+
test("nothing for a path inside an array or a wrapper object", () => {
|
|
526
|
+
expect(tagPlacement({
|
|
527
|
+
...base,
|
|
528
|
+
properties: { TagSpecifications: { type: "array" } },
|
|
529
|
+
tagging: { taggable: true, tagProperty: "/properties/TagSpecifications/*/Tags" },
|
|
530
|
+
} as CFNSchema)).toEqual({});
|
|
531
|
+
expect(tagPlacement({
|
|
532
|
+
...base,
|
|
533
|
+
properties: { Tags: { type: "object", properties: { Items: { type: "array" } } } },
|
|
534
|
+
tagging: { taggable: true, tagProperty: "/properties/Tags" },
|
|
535
|
+
} as CFNSchema)).toEqual({});
|
|
536
|
+
});
|
|
537
|
+
});
|
package/src/spec/parse.ts
CHANGED
|
@@ -79,7 +79,24 @@ export interface ParsedResource {
|
|
|
79
79
|
inferredDeprecations: string[];
|
|
80
80
|
conditionalCreateOnly: string[];
|
|
81
81
|
replacementStrategy?: "delete_then_create" | "create_then_delete";
|
|
82
|
-
tagging?:
|
|
82
|
+
tagging?: ResourceTagging;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How a type's tags are written: a `[{Key, Value}]` list or a key-to-value map. */
|
|
86
|
+
export type TagShape = "list" | "map";
|
|
87
|
+
|
|
88
|
+
export interface ResourceTagging {
|
|
89
|
+
taggable: boolean;
|
|
90
|
+
tagOnCreate: boolean;
|
|
91
|
+
tagUpdatable: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The top-level property the type's tags live in (`Tags`, `UserPoolTags`, ...)
|
|
94
|
+
* and its shape, read from the schema's `tagging.tagProperty` (#4350). Both are
|
|
95
|
+
* absent when the tags sit somewhere the serializer can't stamp, such as
|
|
96
|
+
* a path inside an array (`TagSpecifications[].Tags`) or an `{Items: [...]}` wrapper.
|
|
97
|
+
*/
|
|
98
|
+
tagProperty?: string;
|
|
99
|
+
tagShape?: TagShape;
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
export interface SchemaParseResult {
|
|
@@ -202,6 +219,7 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
202
219
|
taggable: true,
|
|
203
220
|
tagOnCreate: schema.tagging.tagOnCreate ?? false,
|
|
204
221
|
tagUpdatable: schema.tagging.tagUpdatable ?? false,
|
|
222
|
+
...tagPlacement(schema),
|
|
205
223
|
};
|
|
206
224
|
}
|
|
207
225
|
|
|
@@ -242,6 +260,70 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
242
260
|
|
|
243
261
|
// --- Type resolution (delegated to core) ---
|
|
244
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Where a taggable type's tags go and in which shape (#4350). The property is
|
|
265
|
+
* `tagging.tagProperty` when it names one top-level property, else `Tags` when
|
|
266
|
+
* the type has one. The shape is a list when the items carry `Key` and `Value`
|
|
267
|
+
* (directly or as one `oneOf` branch) and a map when the property is an object
|
|
268
|
+
* with no fixed properties. Anything else returns `{}`, so the type is skipped.
|
|
269
|
+
*/
|
|
270
|
+
export function tagPlacement(schema: CFNSchema): { tagProperty?: string; tagShape?: TagShape } {
|
|
271
|
+
const pointer = schema.tagging?.tagProperty;
|
|
272
|
+
let name: string | undefined;
|
|
273
|
+
if (pointer !== undefined) {
|
|
274
|
+
const m = /^\/properties\/([^/]+)$/.exec(pointer);
|
|
275
|
+
if (!m) return {};
|
|
276
|
+
name = m[1];
|
|
277
|
+
} else if (schema.properties?.Tags) {
|
|
278
|
+
name = "Tags";
|
|
279
|
+
}
|
|
280
|
+
if (name === undefined) return {};
|
|
281
|
+
const prop = resolveLocalRef(schema.properties?.[name], schema);
|
|
282
|
+
if (!prop) return {};
|
|
283
|
+
const shape = tagShapeOf(prop, schema);
|
|
284
|
+
return shape ? { tagProperty: name, tagShape: shape } : {};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type LooseSchema = Record<string, unknown> & {
|
|
288
|
+
$ref?: string;
|
|
289
|
+
type?: string | string[];
|
|
290
|
+
items?: unknown;
|
|
291
|
+
properties?: Record<string, unknown>;
|
|
292
|
+
oneOf?: unknown[];
|
|
293
|
+
anyOf?: unknown[];
|
|
294
|
+
patternProperties?: unknown;
|
|
295
|
+
additionalProperties?: unknown;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function resolveLocalRef(value: unknown, schema: CFNSchema): LooseSchema | undefined {
|
|
299
|
+
let node = value as LooseSchema | undefined;
|
|
300
|
+
for (let i = 0; i < 8 && node && typeof node.$ref === "string"; i++) {
|
|
301
|
+
const def = refDefinitionName(node.$ref);
|
|
302
|
+
node = def ? (schema.definitions?.[def] as LooseSchema | undefined) : undefined;
|
|
303
|
+
}
|
|
304
|
+
return node && typeof node === "object" ? node : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hasType(node: LooseSchema, t: string): boolean {
|
|
308
|
+
return node.type === t || (Array.isArray(node.type) && node.type.includes(t));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isKeyValueItem(node: LooseSchema | undefined, schema: CFNSchema): boolean {
|
|
312
|
+
if (!node) return false;
|
|
313
|
+
if (node.properties && "Key" in node.properties && "Value" in node.properties) return true;
|
|
314
|
+
const branches = [...(node.oneOf ?? []), ...(node.anyOf ?? [])];
|
|
315
|
+
return branches.some((b) => isKeyValueItem(resolveLocalRef(b, schema), schema));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function tagShapeOf(prop: LooseSchema, schema: CFNSchema): TagShape | undefined {
|
|
319
|
+
if (hasType(prop, "array")) {
|
|
320
|
+
return isKeyValueItem(resolveLocalRef(prop.items, schema), schema) ? "list" : undefined;
|
|
321
|
+
}
|
|
322
|
+
const objectish = hasType(prop, "object") || prop.patternProperties !== undefined || prop.additionalProperties !== undefined;
|
|
323
|
+
if (objectish && (!prop.properties || Object.keys(prop.properties).length === 0)) return "map";
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
245
327
|
function resolvePropertyType(prop: SchemaProperty | undefined, schema: CFNSchema): string {
|
|
246
328
|
const shortName = cfnShortName(schema.typeName);
|
|
247
329
|
return coreResolvePropertyType(
|