@intentius/chant-lexicon-aws 0.56.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentcore/trace-fetch.d.ts +5 -4
- package/dist/agentcore/trace-fetch.d.ts.map +1 -1
- package/dist/condition.d.ts +27 -0
- package/dist/condition.d.ts.map +1 -0
- package/dist/generated/index.d.ts +1 -1
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts +19 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts +19 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -3
- package/dist/intrinsics.d.ts +77 -3
- package/dist/intrinsics.d.ts.map +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +10 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw069.d.ts +15 -0
- package/dist/lint/post-synth/waw069.d.ts.map +1 -0
- package/dist/manifest.json +29 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/WAW069.md +11 -0
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/floci.d.ts +3 -2
- package/dist/op/activities/floci.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -4
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/waw069.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/package.json +2 -2
- package/src/agentcore/trace-fetch.ts +5 -4
- package/src/codegen/docs.ts +3 -3
- package/src/codegen/generate-typescript.ts +3 -3
- package/src/codegen/generate.ts +1 -1
- package/src/condition.test.ts +129 -0
- package/src/condition.ts +40 -0
- package/src/deep-observe.test.ts +23 -15
- package/src/deep-topology.test.ts +7 -3
- package/src/generated/index.d.ts +3 -3
- package/src/generated/index.ts +1 -1
- package/src/import/generator.ts +131 -50
- package/src/import/parser.test.ts +96 -0
- package/src/import/parser.ts +86 -1
- package/src/import/roundtrip-fixtures.test.ts +50 -0
- package/src/index.ts +15 -3
- package/src/intrinsics.ts +130 -5
- package/src/lint/audit-catalog.ts +6 -1
- package/src/lint/audit-lineage.ts +159 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/waw069.test.ts +94 -0
- package/src/lint/post-synth/waw069.ts +0 -0
- package/src/op/activities/aws-apply.ts +1 -3
- package/src/op/activities/floci.ts +3 -2
- package/src/op/builders.ts +4 -4
- package/src/plugin.test.ts +5 -1
- package/src/plugin.ts +4 -0
- package/src/serializer.ts +24 -3
- package/src/testdata/roundtrip/with-conditions.json +16 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { Condition, isCondition, CONDITION_ENTITY_TYPE } from "./condition";
|
|
3
|
+
import { Equals, And, Or, Not, If, Ref } from "./intrinsics";
|
|
4
|
+
import { Parameter } from "./parameter";
|
|
5
|
+
import { awsSerializer } from "./serializer";
|
|
6
|
+
import { stackOutput } from "@intentius/chant/stack-output";
|
|
7
|
+
import { createResource } from "@intentius/chant/runtime";
|
|
8
|
+
import { resolveAttrRefs } from "@intentius/chant/discovery/resolve";
|
|
9
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
|
|
11
|
+
const LogGroup = createResource("AWS::Logs::LogGroup", "aws", {});
|
|
12
|
+
|
|
13
|
+
function serialize(entities: Map<string, Declarable>): Record<string, any> {
|
|
14
|
+
resolveAttrRefs(entities);
|
|
15
|
+
return JSON.parse(awsSerializer.serialize(entities) as string);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("Condition declarable", () => {
|
|
19
|
+
test("carries the aws lexicon and the condition entity type", () => {
|
|
20
|
+
const cond = new Condition(Equals("a", "b"));
|
|
21
|
+
expect(cond.lexicon).toBe("aws");
|
|
22
|
+
expect(cond.entityType).toBe(CONDITION_ENTITY_TYPE);
|
|
23
|
+
expect(isCondition(cond)).toBe(true);
|
|
24
|
+
expect(isCondition(new Parameter("String"))).toBe(false);
|
|
25
|
+
expect(isCondition("DoCutover")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects a non-intrinsic expression", () => {
|
|
29
|
+
expect(() => new Condition("true" as never)).toThrow(/condition intrinsic/);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("condition intrinsics", () => {
|
|
34
|
+
test("Equals resolves value intrinsics", () => {
|
|
35
|
+
const param = new Parameter("String");
|
|
36
|
+
const entities = new Map<string, Declarable>([["Cutover", param]]);
|
|
37
|
+
resolveAttrRefs(entities);
|
|
38
|
+
expect(JSON.parse(JSON.stringify(Equals(Ref(param), "true")))).toEqual({
|
|
39
|
+
"Fn::Equals": [{ Ref: "Cutover" }, "true"],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Not over a Condition declarable emits the Condition reference form", () => {
|
|
44
|
+
const cond = new Condition(Equals("a", "b"));
|
|
45
|
+
const entities = new Map<string, Declarable>([["DoCutover", cond]]);
|
|
46
|
+
resolveAttrRefs(entities);
|
|
47
|
+
expect(JSON.parse(JSON.stringify(Not(cond)))).toEqual({
|
|
48
|
+
"Fn::Not": [{ Condition: "DoCutover" }],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("And/Or accept names, declarables, and nested intrinsics", () => {
|
|
53
|
+
expect(JSON.parse(JSON.stringify(And("A", Equals("x", "y"))))).toEqual({
|
|
54
|
+
"Fn::And": [{ Condition: "A" }, { "Fn::Equals": ["x", "y"] }],
|
|
55
|
+
});
|
|
56
|
+
expect(JSON.parse(JSON.stringify(Or("A", Not("B"))))).toEqual({
|
|
57
|
+
"Fn::Or": [{ Condition: "A" }, { "Fn::Not": [{ Condition: "B" }] }],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("And/Or enforce CloudFormation's 2–10 operand bounds", () => {
|
|
62
|
+
expect(() => And("A")).toThrow(/between 2 and 10/);
|
|
63
|
+
expect(() => Or("A")).toThrow(/between 2 and 10/);
|
|
64
|
+
const eleven = Array.from({ length: 11 }, (_, i) => `C${i}`);
|
|
65
|
+
expect(() => And(...eleven)).toThrow(/between 2 and 10/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("If accepts the Condition declarable as the condition", () => {
|
|
69
|
+
const cond = new Condition(Equals("a", "b"));
|
|
70
|
+
const entities = new Map<string, Declarable>([["DoCutover", cond]]);
|
|
71
|
+
resolveAttrRefs(entities);
|
|
72
|
+
expect(JSON.parse(JSON.stringify(If(cond, "on", "off")))).toEqual({
|
|
73
|
+
"Fn::If": ["DoCutover", "on", "off"],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("serializer Conditions section (#2068)", () => {
|
|
79
|
+
test("Condition declarables are lifted into Conditions", () => {
|
|
80
|
+
const param = new Parameter("String", { defaultValue: "false" });
|
|
81
|
+
const doCutover = new Condition(Equals(Ref(param), "true"));
|
|
82
|
+
const noCutover = new Condition(Not(doCutover));
|
|
83
|
+
const template = serialize(
|
|
84
|
+
new Map<string, Declarable>([
|
|
85
|
+
["Cutover", param],
|
|
86
|
+
["DoCutover", doCutover],
|
|
87
|
+
["NoCutover", noCutover],
|
|
88
|
+
]),
|
|
89
|
+
);
|
|
90
|
+
expect(template.Conditions).toEqual({
|
|
91
|
+
DoCutover: { "Fn::Equals": [{ Ref: "Cutover" }, "true"] },
|
|
92
|
+
NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] },
|
|
93
|
+
});
|
|
94
|
+
// Never emitted as a Resource
|
|
95
|
+
expect(template.Resources).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("resource-level Condition accepts the declarable as well as a string", () => {
|
|
99
|
+
const doCutover = new Condition(Equals("a", "b"));
|
|
100
|
+
const byRef = new LogGroup({ RetentionInDays: 7 }, { Condition: doCutover });
|
|
101
|
+
const byName = new LogGroup({ RetentionInDays: 7 }, { Condition: "DoCutover" });
|
|
102
|
+
const template = serialize(
|
|
103
|
+
new Map<string, Declarable>([
|
|
104
|
+
["DoCutover", doCutover],
|
|
105
|
+
["ByRef", byRef],
|
|
106
|
+
["ByName", byName],
|
|
107
|
+
]),
|
|
108
|
+
);
|
|
109
|
+
expect(template.Resources.ByRef.Condition).toBe("DoCutover");
|
|
110
|
+
expect(template.Resources.ByName.Condition).toBe("DoCutover");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("stackOutput condition emits the output-level Condition key", () => {
|
|
114
|
+
const doCutover = new Condition(Equals("a", "b"));
|
|
115
|
+
const group = new LogGroup({ RetentionInDays: 7 }, { Condition: doCutover });
|
|
116
|
+
const out = stackOutput(Ref(group), { condition: doCutover });
|
|
117
|
+
const outByName = stackOutput(Ref(group), { condition: "DoCutover" });
|
|
118
|
+
const template = serialize(
|
|
119
|
+
new Map<string, Declarable>([
|
|
120
|
+
["DoCutover", doCutover],
|
|
121
|
+
["Rule", group],
|
|
122
|
+
["RuleName", out],
|
|
123
|
+
["RuleNameByName", outByName],
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
expect(template.Outputs.RuleName).toEqual({ Value: { Ref: "Rule" }, Condition: "DoCutover" });
|
|
127
|
+
expect(template.Outputs.RuleNameByName.Condition).toBe("DoCutover");
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/condition.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFormation template condition (#2068).
|
|
3
|
+
*
|
|
4
|
+
* A `Condition` declarable is lifted into the template's `Conditions`
|
|
5
|
+
* section by the serializer, the way `Parameter` is lifted into
|
|
6
|
+
* `Parameters`. Reference it from a resource's `Condition` attribute, an
|
|
7
|
+
* output's `condition` option, `If(...)`, or inside another condition via
|
|
8
|
+
* `And`/`Or`/`Not`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DECLARABLE_MARKER, isDeclarable, type Declarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { isIntrinsic, type Intrinsic } from "@intentius/chant/intrinsic";
|
|
13
|
+
|
|
14
|
+
export const CONDITION_ENTITY_TYPE = "AWS::CloudFormation::Condition";
|
|
15
|
+
|
|
16
|
+
export class Condition implements Declarable {
|
|
17
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
18
|
+
readonly lexicon = "aws";
|
|
19
|
+
readonly entityType = CONDITION_ENTITY_TYPE;
|
|
20
|
+
/** The boolean expression: an `Equals`/`And`/`Or`/`Not` intrinsic. */
|
|
21
|
+
readonly expression: Intrinsic;
|
|
22
|
+
|
|
23
|
+
constructor(expression: Intrinsic) {
|
|
24
|
+
if (!isIntrinsic(expression)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"new Condition(expression): expression must be a condition intrinsic (Equals, And, Or, Not)",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
this.expression = expression;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Type guard for the `Condition` declarable. Duck-typed on `entityType`
|
|
35
|
+
* rather than `instanceof` so a lexicon built against a separate copy of
|
|
36
|
+
* `@intentius/chant` still matches (the #1137 convention).
|
|
37
|
+
*/
|
|
38
|
+
export function isCondition(value: unknown): value is Condition {
|
|
39
|
+
return isDeclarable(value) && value.entityType === CONDITION_ENTITY_TYPE;
|
|
40
|
+
}
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -949,12 +949,15 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
949
949
|
// all subtracted.
|
|
950
950
|
expect(result.unchanged).toEqual(["AppRole"]);
|
|
951
951
|
|
|
952
|
-
// The platform team's tag is
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
952
|
+
// The platform team's tag is on a path source never declared, so since
|
|
953
|
+
// #2160 the claim answers before the baseline gets a chance to: it is held
|
|
954
|
+
// elsewhere, at its live value, and it is not drift. AWS records no field
|
|
955
|
+
// manager, so the claimed-field set is the only source that can answer.
|
|
956
|
+
expect(result.accepted).toEqual([]);
|
|
957
|
+
expect(result.unclaimed.map((e) => e.name)).toEqual(["Assets"]);
|
|
958
|
+
expect(result.unclaimed[0].fields).toEqual([
|
|
959
|
+
{ path: "Tags[#cost-center].Key", live: "cost-center", source: "claimed-fields", baseline: "cost-center" },
|
|
960
|
+
{ path: "Tags[#cost-center].Value", live: "platform", source: "claimed-fields", baseline: "platform" },
|
|
958
961
|
]);
|
|
959
962
|
|
|
960
963
|
// An unreadable deep read is a hole with a reason — never silence, never
|
|
@@ -976,7 +979,10 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
976
979
|
]);
|
|
977
980
|
});
|
|
978
981
|
|
|
979
|
-
test("
|
|
982
|
+
test("the claim silences the platform tag with no baseline at all (#2160)", async () => {
|
|
983
|
+
// Before #2160 this tag was drift until somebody accepted it. The
|
|
984
|
+
// declaration is the table AWS cannot provide, and it answers on the first
|
|
985
|
+
// read, with nothing recorded anywhere.
|
|
980
986
|
wireMocks();
|
|
981
987
|
const result = await deepDiffForLexicon(awsPlugin, {
|
|
982
988
|
environment: "prod",
|
|
@@ -984,15 +990,16 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
984
990
|
entities: declared,
|
|
985
991
|
});
|
|
986
992
|
const assets = result.drifted.find((d) => d.name === "Assets");
|
|
987
|
-
expect(assets?.changes.map((c) => c.path)
|
|
993
|
+
expect(assets?.changes.map((c) => c.path)).toEqual(["VersioningConfiguration.Status"]);
|
|
994
|
+
expect(result.unclaimed[0].fields.map((f) => f.path)).toEqual([
|
|
988
995
|
"Tags[#cost-center].Key",
|
|
989
996
|
"Tags[#cost-center].Value",
|
|
990
|
-
"VersioningConfiguration.Status",
|
|
991
997
|
]);
|
|
992
998
|
expect(result.accepted).toEqual([]);
|
|
993
999
|
});
|
|
994
1000
|
|
|
995
1001
|
test("an accepted value that later changes is drift again, with all three axes", async () => {
|
|
1002
|
+
// The baseline's value-bound rule, on the path that is actually declared.
|
|
996
1003
|
wireMocks();
|
|
997
1004
|
const result = await deepDiffForLexicon(awsPlugin, {
|
|
998
1005
|
environment: "prod",
|
|
@@ -1000,18 +1007,19 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
1000
1007
|
entities: declared,
|
|
1001
1008
|
baseline: {
|
|
1002
1009
|
Assets: {
|
|
1003
|
-
accepted: [{ path: "
|
|
1010
|
+
accepted: [{ path: "VersioningConfiguration.Status", value: "Enabled" }],
|
|
1004
1011
|
},
|
|
1005
1012
|
},
|
|
1006
1013
|
});
|
|
1007
1014
|
const change = result.drifted
|
|
1008
1015
|
.find((d) => d.name === "Assets")
|
|
1009
|
-
?.changes.find((c) => c.path === "
|
|
1016
|
+
?.changes.find((c) => c.path === "VersioningConfiguration.Status");
|
|
1010
1017
|
expect(change).toEqual({
|
|
1011
|
-
path: "
|
|
1012
|
-
kind: "
|
|
1013
|
-
|
|
1014
|
-
|
|
1018
|
+
path: "VersioningConfiguration.Status",
|
|
1019
|
+
kind: "changed",
|
|
1020
|
+
declared: "Enabled",
|
|
1021
|
+
live: "Suspended",
|
|
1022
|
+
baseline: "Enabled",
|
|
1015
1023
|
});
|
|
1016
1024
|
});
|
|
1017
1025
|
|
|
@@ -360,7 +360,7 @@ describe("the fold answers (#1269)", () => {
|
|
|
360
360
|
]);
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
test("an out-of-band MapPublicIpOnLaunch flip reports; the service default does not", async () => {
|
|
363
|
+
test("an out-of-band MapPublicIpOnLaunch flip reports as unclaimed; the service default does not", async () => {
|
|
364
364
|
const subnetRow = (mapPublic: boolean) => ({
|
|
365
365
|
Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: mapPublic }],
|
|
366
366
|
});
|
|
@@ -381,11 +381,15 @@ describe("the fold answers (#1269)", () => {
|
|
|
381
381
|
|
|
382
382
|
ec2Describes({ "describe-subnets": subnetRow(true) });
|
|
383
383
|
const diff = diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks);
|
|
384
|
-
|
|
384
|
+
// Source never set MapPublicIpOnLaunch, so the flip is somebody else's
|
|
385
|
+
// (#2160): reported with its value, not counted as drift, and never
|
|
386
|
+
// proposed for an update.
|
|
387
|
+
expect(diff.drifted).toEqual([]);
|
|
388
|
+
expect(diff.unclaimed).toEqual([
|
|
385
389
|
{
|
|
386
390
|
name: "PublicSubnet",
|
|
387
391
|
type: "AWS::EC2::Subnet",
|
|
388
|
-
|
|
392
|
+
fields: [{ path: "MapPublicIpOnLaunch", live: true, source: "claimed-fields" }],
|
|
389
393
|
},
|
|
390
394
|
]);
|
|
391
395
|
});
|
package/src/generated/index.d.ts
CHANGED
|
@@ -135131,14 +135131,14 @@ export type ZonalAutoshiftConfiguration_ZonalAutoshiftStatus = "ENABLED";
|
|
|
135131
135131
|
// --- Intrinsic functions ---
|
|
135132
135132
|
|
|
135133
135133
|
export declare function And(conditions: unknown[]): any;
|
|
135134
|
+
export declare function And(...conditions: unknown[]): any;
|
|
135134
135135
|
export declare function Base64(value: unknown): any;
|
|
135135
135136
|
export declare function Cidr(ipBlock: unknown, count: unknown, cidrBits?: unknown): any;
|
|
135136
|
-
export declare function Condition(condition: string): any;
|
|
135137
135137
|
export declare function Equals(a: unknown, b: unknown): any;
|
|
135138
135138
|
export declare function FindInMap(mapName: string, firstKey: unknown, secondKey: unknown): any;
|
|
135139
135139
|
export declare function GetAtt(logicalName: string, attribute: string): any;
|
|
135140
135140
|
export declare function GetAZs(region?: string): any;
|
|
135141
|
-
export declare function If(condition: string, valueIfTrue: unknown, valueIfFalse: unknown): any;
|
|
135141
|
+
export declare function If(condition: string | Declarable, valueIfTrue: unknown, valueIfFalse: unknown): any;
|
|
135142
135142
|
export declare function ImportValue(sharedValue: unknown): any;
|
|
135143
135143
|
export declare function Join(delimiter: string, values: unknown[]): any;
|
|
135144
135144
|
export declare function Not(condition: unknown): any;
|
|
@@ -135170,7 +135170,7 @@ export interface Declarable {
|
|
|
135170
135170
|
|
|
135171
135171
|
export interface CFResourceAttributes {
|
|
135172
135172
|
DependsOn?: Declarable | Declarable[] | string | string[];
|
|
135173
|
-
Condition?: string;
|
|
135173
|
+
Condition?: string | Declarable;
|
|
135174
135174
|
DeletionPolicy?: "Delete" | "Retain" | "RetainExceptOnCreate" | "Snapshot";
|
|
135175
135175
|
UpdateReplacePolicy?: "Delete" | "Retain" | "Snapshot";
|
|
135176
135176
|
UpdatePolicy?: {
|
package/src/generated/index.ts
CHANGED
|
@@ -10765,5 +10765,5 @@ export const ZonalShiftConfig = createProperty("AWS::EKS::Cluster.ZonalShiftConf
|
|
|
10765
10765
|
export const ZookeeperAccess = createProperty("AWS::MSK::Cluster.ZookeeperAccess", "aws");
|
|
10766
10766
|
|
|
10767
10767
|
// Re-exports for convenience
|
|
10768
|
-
export { Sub, Ref, GetAtt, If, Join, Select, Split, Base64 } from "../intrinsics";
|
|
10768
|
+
export { Sub, Ref, GetAtt, If, Join, Select, Split, Base64, Equals, Not } from "../intrinsics";
|
|
10769
10769
|
export { AWS, StackName, Region, AccountId, StackId, URLSuffix, NoValue, NotificationARNs } from "../pseudo";
|
package/src/import/generator.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadLexiconRegistry } from "@intentius/chant/codegen/registry";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
|
-
import type { TemplateIR, ResourceIR, ParameterIR } from "@intentius/chant/import/parser";
|
|
3
|
+
import type { TemplateIR, ResourceIR, ParameterIR, ConditionIR, OutputIR } from "@intentius/chant/import/parser";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
|
|
6
6
|
import { topoSort } from "@intentius/chant/codegen/topo-sort";
|
|
@@ -51,12 +51,33 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
51
51
|
lines.push("");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Generate conditions in dependency order ({ Condition: ... } references
|
|
55
|
+
// point at earlier declarations) — #2069
|
|
56
|
+
const conditions = ir.conditions ?? [];
|
|
57
|
+
const sortedConditions = this.sortConditions(conditions);
|
|
58
|
+
for (const condition of sortedConditions) {
|
|
59
|
+
lines.push(this.generateCondition(condition, ir, importedSymbols));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (conditions.length > 0) {
|
|
63
|
+
lines.push("");
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
// Generate resources in dependency order
|
|
55
67
|
const sortedResources = this.sortByDependencies(ir.resources);
|
|
56
68
|
for (const resource of sortedResources) {
|
|
57
69
|
lines.push(this.generateResource(resource, ir, importedSymbols));
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
// Generate outputs (#2069)
|
|
73
|
+
const outputs = ir.outputs ?? [];
|
|
74
|
+
if (outputs.length > 0) {
|
|
75
|
+
lines.push("");
|
|
76
|
+
for (const output of outputs) {
|
|
77
|
+
lines.push(this.generateOutput(output, ir, importedSymbols));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
return [
|
|
61
82
|
{
|
|
62
83
|
path: "main.ts",
|
|
@@ -71,10 +92,14 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
71
92
|
private collectImportedSymbols(ir: TemplateIR): Set<string> {
|
|
72
93
|
const symbols = new Set<string>();
|
|
73
94
|
if (ir.parameters.length > 0) symbols.add("Parameter");
|
|
74
|
-
|
|
95
|
+
if ((ir.conditions ?? []).length > 0) symbols.add("Condition");
|
|
96
|
+
if ((ir.outputs ?? []).length > 0) symbols.add("stackOutput");
|
|
97
|
+
const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "GetAtt", "Equals", "And", "Or", "Not"] as const;
|
|
75
98
|
for (const name of intrinsics) {
|
|
76
99
|
if (irUsesIntrinsic(ir, name)) symbols.add(name);
|
|
77
100
|
}
|
|
101
|
+
// Outputs whose value is a Ref envelope render as Ref(<var>) (#2069)
|
|
102
|
+
if ((ir.outputs ?? []).some((o) => hasIntrinsicInValue(o.value, "Ref"))) symbols.add("Ref");
|
|
78
103
|
if (this.needsAWSPseudo(ir)) symbols.add("AWS");
|
|
79
104
|
for (const resource of ir.resources) {
|
|
80
105
|
const parsed = this.parseResourceType(resource.type);
|
|
@@ -94,54 +119,14 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
94
119
|
* Generate import statements
|
|
95
120
|
*/
|
|
96
121
|
private generateImports(ir: TemplateIR): string {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
imports.add("Parameter");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Check for intrinsics
|
|
107
|
-
const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "GetAtt"] as const;
|
|
108
|
-
for (const name of intrinsics) {
|
|
109
|
-
if (irUsesIntrinsic(ir, name)) imports.add(name);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Check for AWS pseudo-parameters
|
|
113
|
-
if (this.needsAWSPseudo(ir)) {
|
|
114
|
-
imports.add("AWS");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Collect service imports (skip unknown resource types)
|
|
118
|
-
for (const resource of ir.resources) {
|
|
119
|
-
const parsed = this.parseResourceType(resource.type);
|
|
120
|
-
if (!parsed) continue;
|
|
121
|
-
const { service, resourceClass } = parsed;
|
|
122
|
-
if (!serviceImports.has(service)) {
|
|
123
|
-
serviceImports.set(service, new Set());
|
|
124
|
-
}
|
|
125
|
-
serviceImports.get(service)!.add(resourceClass);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Build import lines
|
|
129
|
-
const importLines: string[] = [];
|
|
130
|
-
|
|
131
|
-
// Merge service resource imports into the core imports set
|
|
132
|
-
for (const [_service, resources] of serviceImports) {
|
|
133
|
-
for (const r of resources) {
|
|
134
|
-
imports.add(r);
|
|
135
|
-
}
|
|
122
|
+
// Everything comes from the flat @intentius/chant-lexicon-aws package,
|
|
123
|
+
// and the needed symbol set is exactly what collectImportedSymbols
|
|
124
|
+
// computes for variable-name conflict detection.
|
|
125
|
+
const allImports = [...this.collectImportedSymbols(ir)];
|
|
126
|
+
if (allImports.length === 0) {
|
|
127
|
+
return "";
|
|
136
128
|
}
|
|
137
|
-
|
|
138
|
-
// All imports come from the flat @intentius/chant-lexicon-aws package
|
|
139
|
-
const allImports = [...imports];
|
|
140
|
-
if (allImports.length > 0) {
|
|
141
|
-
importLines.push(`import { ${allImports.join(", ")} } from "@intentius/chant-lexicon-aws";`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return importLines.join("\n");
|
|
129
|
+
return `import { ${allImports.join(", ")} } from "@intentius/chant-lexicon-aws";`;
|
|
145
130
|
}
|
|
146
131
|
|
|
147
132
|
/**
|
|
@@ -237,6 +222,76 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
237
222
|
);
|
|
238
223
|
}
|
|
239
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Sort conditions so `{ Condition: ... }` references point at earlier
|
|
227
|
+
* declarations (#2069).
|
|
228
|
+
*/
|
|
229
|
+
private sortConditions(conditions: ConditionIR[]): ConditionIR[] {
|
|
230
|
+
return topoSort(
|
|
231
|
+
conditions,
|
|
232
|
+
(c) => c.name,
|
|
233
|
+
(c) => [
|
|
234
|
+
...collectDependencies(c.expression, (obj) =>
|
|
235
|
+
obj.__intrinsic === "ConditionRef" ? (obj.name as string) : null,
|
|
236
|
+
),
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render a condition reference as the condition's variable when the
|
|
243
|
+
* template declares it, or as a literal name string when it doesn't (an
|
|
244
|
+
* undeclared reference stays visible rather than breaking generation).
|
|
245
|
+
*/
|
|
246
|
+
private conditionVarRef(name: string, ir: TemplateIR, importedSymbols: Set<string>): string {
|
|
247
|
+
const declared = (ir.conditions ?? []).some((c) => c.name === name);
|
|
248
|
+
return declared ? this.safeVarName(name, importedSymbols) : JSON.stringify(name);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Generate a condition declaration (#2069)
|
|
253
|
+
*/
|
|
254
|
+
private generateCondition(condition: ConditionIR, ir: TemplateIR, importedSymbols: Set<string>): string {
|
|
255
|
+
const varName = this.safeVarName(condition.name, importedSymbols);
|
|
256
|
+
const exprStr = this.generateValue(condition.expression, ir, importedSymbols);
|
|
257
|
+
return `export const ${varName} = new Condition(${exprStr});`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Generate an output declaration as stackOutput(...) (#2069)
|
|
262
|
+
*/
|
|
263
|
+
private generateOutput(output: OutputIR, ir: TemplateIR, importedSymbols: Set<string>): string {
|
|
264
|
+
const varName = this.safeVarName(output.name, importedSymbols);
|
|
265
|
+
|
|
266
|
+
// A bare Ref envelope becomes Ref(<var>) — stackOutput takes an intrinsic
|
|
267
|
+
// or attribute reference, not the resource/parameter object itself.
|
|
268
|
+
let valueStr: string;
|
|
269
|
+
const value = output.value as Record<string, unknown> | null;
|
|
270
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && value.__intrinsic === "Ref" && !(value.name as string).startsWith("AWS::")) {
|
|
271
|
+
valueStr = `Ref(${this.safeVarName(value.name as string, importedSymbols)})`;
|
|
272
|
+
} else {
|
|
273
|
+
valueStr = this.generateValue(output.value, ir, importedSymbols);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const opts: string[] = [];
|
|
277
|
+
if (output.description) opts.push(`description: ${JSON.stringify(output.description)}`);
|
|
278
|
+
if (output.exportName !== undefined) {
|
|
279
|
+
opts.push(`exportName: ${this.generateValue(output.exportName, ir, importedSymbols)}`);
|
|
280
|
+
}
|
|
281
|
+
if (output.condition) {
|
|
282
|
+
opts.push(`condition: ${this.conditionVarRef(output.condition, ir, importedSymbols)}`);
|
|
283
|
+
}
|
|
284
|
+
// A literal output has no entity to derive its lexicon from.
|
|
285
|
+
if (typeof output.value === "string") {
|
|
286
|
+
opts.push(`lexicon: "aws"`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (opts.length > 0) {
|
|
290
|
+
return `export const ${varName} = stackOutput(${valueStr}, { ${opts.join(", ")} });`;
|
|
291
|
+
}
|
|
292
|
+
return `export const ${varName} = stackOutput(${valueStr});`;
|
|
293
|
+
}
|
|
294
|
+
|
|
240
295
|
/**
|
|
241
296
|
* Generate a parameter declaration
|
|
242
297
|
*/
|
|
@@ -264,6 +319,12 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
264
319
|
const { resourceClass } = parsed;
|
|
265
320
|
const propsStr = this.generateProps(resource.properties, ir, importedSymbols);
|
|
266
321
|
|
|
322
|
+
// Resource-level Condition key → the attributes argument (#2069)
|
|
323
|
+
if (resource.condition) {
|
|
324
|
+
const condRef = this.conditionVarRef(resource.condition, ir, importedSymbols);
|
|
325
|
+
return `export const ${varName} = new ${resourceClass}(${propsStr}, { Condition: ${condRef} });`;
|
|
326
|
+
}
|
|
327
|
+
|
|
267
328
|
if (propsStr === "{}") {
|
|
268
329
|
return `export const ${varName} = new ${resourceClass}();`;
|
|
269
330
|
}
|
|
@@ -344,9 +405,29 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
344
405
|
|
|
345
406
|
if (obj.__intrinsic === "If") {
|
|
346
407
|
const condition = obj.condition as string;
|
|
408
|
+
const condRef = this.conditionVarRef(condition, ir, importedSymbols);
|
|
347
409
|
const trueVal = this.generateValue(obj.valueIfTrue, ir, importedSymbols);
|
|
348
410
|
const falseVal = this.generateValue(obj.valueIfFalse, ir, importedSymbols);
|
|
349
|
-
return `If(
|
|
411
|
+
return `If(${condRef}, ${trueVal}, ${falseVal})`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (obj.__intrinsic === "Equals") {
|
|
415
|
+
const left = this.generateValue(obj.left, ir, importedSymbols);
|
|
416
|
+
const right = this.generateValue(obj.right, ir, importedSymbols);
|
|
417
|
+
return `Equals(${left}, ${right})`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (obj.__intrinsic === "And" || obj.__intrinsic === "Or") {
|
|
421
|
+
const operands = (obj.conditions as unknown[]).map((c) => this.generateValue(c, ir, importedSymbols));
|
|
422
|
+
return `${obj.__intrinsic}(${operands.join(", ")})`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (obj.__intrinsic === "Not") {
|
|
426
|
+
return `Not(${this.generateValue(obj.condition, ir, importedSymbols)})`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (obj.__intrinsic === "ConditionRef") {
|
|
430
|
+
return this.conditionVarRef(obj.name as string, ir, importedSymbols);
|
|
350
431
|
}
|
|
351
432
|
|
|
352
433
|
if (obj.__intrinsic === "Join") {
|
|
@@ -198,3 +198,99 @@ describe("CFParser", () => {
|
|
|
198
198
|
expect(vars.KEY).toBe("value");
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("CFParser conditions and outputs (#2069)", () => {
|
|
203
|
+
const parser = new CFParser();
|
|
204
|
+
|
|
205
|
+
const template = JSON.stringify({
|
|
206
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
207
|
+
Parameters: { Cutover: { Type: "String", Default: "false" } },
|
|
208
|
+
Conditions: {
|
|
209
|
+
DoCutover: { "Fn::Equals": [{ Ref: "Cutover" }, "true"] },
|
|
210
|
+
NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] },
|
|
211
|
+
},
|
|
212
|
+
Resources: {
|
|
213
|
+
Rule: {
|
|
214
|
+
Type: "AWS::Logs::LogGroup",
|
|
215
|
+
Condition: "DoCutover",
|
|
216
|
+
Properties: { LogGroupName: { "Fn::If": ["DoCutover", "/on", "/off"] } },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
Outputs: {
|
|
220
|
+
RuleName: { Condition: "DoCutover", Value: { Ref: "Rule" }, Description: "d", Export: { Name: "rule-name" } },
|
|
221
|
+
Plain: { Value: "literal" },
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("parses the Conditions section, Condition references included", () => {
|
|
226
|
+
const ir = parser.parse(template);
|
|
227
|
+
expect(ir.conditions).toHaveLength(2);
|
|
228
|
+
expect(ir.conditions![0]).toEqual({
|
|
229
|
+
name: "DoCutover",
|
|
230
|
+
expression: { __intrinsic: "Equals", left: { __intrinsic: "Ref", name: "Cutover" }, right: "true" },
|
|
231
|
+
});
|
|
232
|
+
expect(ir.conditions![1]).toEqual({
|
|
233
|
+
name: "NoCutover",
|
|
234
|
+
expression: { __intrinsic: "Not", condition: { __intrinsic: "ConditionRef", name: "DoCutover" } },
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("carries the resource-level Condition key", () => {
|
|
239
|
+
const ir = parser.parse(template);
|
|
240
|
+
expect(ir.resources[0].condition).toBe("DoCutover");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("parses Outputs with Condition, Description, and Export", () => {
|
|
244
|
+
const ir = parser.parse(template);
|
|
245
|
+
expect(ir.outputs).toHaveLength(2);
|
|
246
|
+
expect(ir.outputs![0]).toEqual({
|
|
247
|
+
name: "RuleName",
|
|
248
|
+
value: { __intrinsic: "Ref", name: "Rule" },
|
|
249
|
+
description: "d",
|
|
250
|
+
exportName: "rule-name",
|
|
251
|
+
condition: "DoCutover",
|
|
252
|
+
});
|
|
253
|
+
expect(ir.outputs![1]).toEqual({
|
|
254
|
+
name: "Plain",
|
|
255
|
+
value: "literal",
|
|
256
|
+
description: undefined,
|
|
257
|
+
exportName: undefined,
|
|
258
|
+
condition: undefined,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a single-key Condition object in resource properties is NOT a condition reference", () => {
|
|
263
|
+
const ir = parser.parse(
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
Resources: {
|
|
266
|
+
Role: {
|
|
267
|
+
Type: "AWS::IAM::Role",
|
|
268
|
+
Properties: {
|
|
269
|
+
Policy: { Condition: "not-a-ref" },
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
expect(ir.resources[0].properties.Policy).toEqual({ Condition: "not-a-ref" });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("names sections import cannot carry instead of dropping them silently", () => {
|
|
279
|
+
const ir = parser.parse(
|
|
280
|
+
JSON.stringify({
|
|
281
|
+
Resources: { R: { Type: "AWS::S3::Bucket" } },
|
|
282
|
+
Mappings: { M: {} },
|
|
283
|
+
Rules: { R1: {} },
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
expect(ir.warnings).toEqual([
|
|
287
|
+
'Template section "Mappings" is not carried by import — it is dropped from the generated source',
|
|
288
|
+
'Template section "Rules" is not carried by import — it is dropped from the generated source',
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("no warnings for fully-carried templates", () => {
|
|
293
|
+
const ir = parser.parse(template);
|
|
294
|
+
expect(ir.warnings).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
});
|