@intentius/chant-lexicon-aws 0.37.2 → 0.40.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/ambient.d.ts.map +1 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +18 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/composites/alb-shared.d.ts +7 -1
- package/dist/composites/alb-shared.d.ts.map +1 -1
- package/dist/composites/ec2-instance-role.d.ts +4 -1
- package/dist/composites/ec2-instance-role.d.ts.map +1 -1
- package/dist/composites/efs-with-access-point.d.ts +5 -1
- package/dist/composites/efs-with-access-point.d.ts.map +1 -1
- package/dist/composites/fargate-alb.d.ts +14 -1
- package/dist/composites/fargate-alb.d.ts.map +1 -1
- package/dist/composites/fargate-service.d.ts +19 -8
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +2 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/lambda-api.d.ts +3 -1
- package/dist/composites/lambda-api.d.ts.map +1 -1
- package/dist/composites/lambda-dynamodb.d.ts +9 -2
- package/dist/composites/lambda-dynamodb.d.ts.map +1 -1
- package/dist/composites/lambda-eventbridge.d.ts +9 -2
- package/dist/composites/lambda-eventbridge.d.ts.map +1 -1
- package/dist/composites/lambda-s3.d.ts +3 -1
- package/dist/composites/lambda-s3.d.ts.map +1 -1
- package/dist/composites/lambda-sns.d.ts +10 -2
- package/dist/composites/lambda-sns.d.ts.map +1 -1
- package/dist/composites/lambda-sqs.d.ts +9 -2
- package/dist/composites/lambda-sqs.d.ts.map +1 -1
- package/dist/composites/microvm-app.d.ts +25 -4
- package/dist/composites/microvm-app.d.ts.map +1 -1
- package/dist/composites/minimal-vpc.d.ts +10 -1
- package/dist/composites/minimal-vpc.d.ts.map +1 -1
- package/dist/composites/rds-instance.d.ts +1 -1
- package/dist/composites/rds-instance.d.ts.map +1 -1
- package/dist/composites/scheduled-lambda.d.ts +15 -3
- package/dist/composites/scheduled-lambda.d.ts.map +1 -1
- package/dist/composites/solr-fargate-service.d.ts +27 -1
- package/dist/composites/solr-fargate-service.d.ts.map +1 -1
- package/dist/generated/index.d.ts +57 -49
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +722 -666
- package/dist/op/activities/floci.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/spec/pin.d.ts +116 -0
- package/dist/spec/pin.d.ts.map +1 -0
- package/dist/spec/pinned-types.json +1652 -0
- package/dist/types/index.d.ts +679 -555
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ambient.test.ts +21 -0
- package/src/ambient.ts +16 -0
- package/src/api/read-client.test.ts +31 -0
- package/src/api/read-client.ts +43 -1
- package/src/codegen/generate.ts +17 -3
- package/src/codegen/naming.ts +31 -2
- package/src/composites/alb-shared.ts +1 -1
- package/src/composites/ec2-instance-role.ts +1 -1
- package/src/composites/efs-with-access-point.ts +1 -1
- package/src/composites/fargate-alb.ts +1 -1
- package/src/composites/fargate-service.ts +15 -10
- package/src/composites/index.ts +2 -2
- package/src/composites/lambda-api.ts +3 -2
- package/src/composites/lambda-dynamodb.ts +4 -3
- package/src/composites/lambda-eventbridge.ts +4 -3
- package/src/composites/lambda-s3.ts +3 -2
- package/src/composites/lambda-sns.ts +4 -3
- package/src/composites/lambda-sqs.ts +4 -3
- package/src/composites/microvm-app.test.ts +39 -1
- package/src/composites/microvm-app.ts +32 -11
- package/src/composites/minimal-vpc.ts +1 -1
- package/src/composites/rds-instance.ts +1 -1
- package/src/composites/scheduled-lambda.ts +4 -3
- package/src/composites/solr-fargate-service.ts +7 -1
- package/src/generated/index.d.ts +679 -555
- package/src/generated/index.ts +58 -50
- package/src/generated/lexicon-aws.json +722 -666
- package/src/import/generator.ts +5 -4
- package/src/index.ts +2 -2
- package/src/lifecycle-integration.test.ts +25 -0
- package/src/op/activities/floci.test.ts +2 -2
- package/src/op/activities/floci.ts +6 -1
- package/src/plugin.ts +4 -0
- package/src/reference-catalog.test.ts +28 -0
- package/src/reference-catalog.ts +7 -0
- package/src/spec/pin.test.ts +248 -0
- package/src/spec/pin.ts +247 -0
- package/src/spec/pinned-types.json +1652 -0
- package/src/validate.ts +5 -0
package/src/import/generator.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadLexiconRegistry } from "@intentius/chant/codegen/registry";
|
|
1
2
|
import { createRequire } from "module";
|
|
2
3
|
import type { TemplateIR, ResourceIR, ParameterIR } from "@intentius/chant/import/parser";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
@@ -14,10 +15,10 @@ export class CFGenerator implements TypeScriptGenerator {
|
|
|
14
15
|
private allClassNames: Set<string>;
|
|
15
16
|
|
|
16
17
|
constructor() {
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// Reverse lookup from the generated registry: resourceType → className.
|
|
19
|
+
// Loaded through core so a missing registry says what to run rather than
|
|
20
|
+
// "Cannot find module .../dist/meta.json" (#1367).
|
|
21
|
+
const meta = loadLexiconRegistry(join(import.meta.dirname, "../.."), "aws");
|
|
21
22
|
this.typeToClass = new Map();
|
|
22
23
|
this.allClassNames = new Set();
|
|
23
24
|
for (const [className, entry] of Object.entries(meta)) {
|
package/src/index.ts
CHANGED
|
@@ -112,7 +112,7 @@ export {
|
|
|
112
112
|
EfsWithAccessPoint,
|
|
113
113
|
Ec2InstanceRole, MinimalVpc,
|
|
114
114
|
SolrFargateService,
|
|
115
|
-
MicrovmApp,
|
|
115
|
+
MicrovmApp, MICROVM_LIMITS,
|
|
116
116
|
AgentCoreAgent,
|
|
117
117
|
} from "./composites/index";
|
|
118
118
|
export type {
|
|
@@ -122,7 +122,7 @@ export type {
|
|
|
122
122
|
EfsWithAccessPointProps,
|
|
123
123
|
Ec2InstanceRoleProps, MinimalVpcProps,
|
|
124
124
|
SolrFargateServiceProps,
|
|
125
|
-
MicrovmAppProps, MicrovmAppResult, MicrovmAppBuildConnectorProps,
|
|
125
|
+
MicrovmAppProps, MicrovmAppResult, MicrovmAppBuildConnectorProps, MicrovmMemoryMiB,
|
|
126
126
|
AgentCoreAgentProps, AgentCoreAgentResult,
|
|
127
127
|
} from "./composites/index";
|
|
128
128
|
|
|
@@ -239,6 +239,7 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
239
239
|
// The shared conformance suite (#1089).
|
|
240
240
|
describeObservationConformance({
|
|
241
241
|
lexicon: "aws",
|
|
242
|
+
ownershipChannel: awsPlugin.ownershipChannel,
|
|
242
243
|
scenarios: [
|
|
243
244
|
{
|
|
244
245
|
name: "a stack read that fails on credentials",
|
|
@@ -286,5 +287,29 @@ describeObservationConformance({
|
|
|
286
287
|
});
|
|
287
288
|
},
|
|
288
289
|
},
|
|
290
|
+
{
|
|
291
|
+
// aws declares a marker channel on the deep read and on live export, but
|
|
292
|
+
// not here: describe-stack-resources returns no tags, so the filter has
|
|
293
|
+
// nothing to filter on. The suite holds it to that — an `owned` verdict
|
|
294
|
+
// from this path would be a claim the transport cannot support (#1348).
|
|
295
|
+
name: "an owned read on a path with no marker channel",
|
|
296
|
+
declared: ["MyBucket"],
|
|
297
|
+
expectPresent: ["MyBucket"],
|
|
298
|
+
owned: true,
|
|
299
|
+
run: () => {
|
|
300
|
+
stubCfn((action) =>
|
|
301
|
+
action === "DescribeStackResources"
|
|
302
|
+
? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
|
|
303
|
+
: { text: stackOutputsXml() },
|
|
304
|
+
);
|
|
305
|
+
return awsPlugin.describeResources!({
|
|
306
|
+
environment: "prod",
|
|
307
|
+
buildOutput: "",
|
|
308
|
+
entityNames: ["MyBucket"],
|
|
309
|
+
entities: new Map(),
|
|
310
|
+
owned: true,
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
},
|
|
289
314
|
],
|
|
290
315
|
});
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
describe("flociRunCommand (#704)", () => {
|
|
14
14
|
test("minimal — defaults name/port/image, no docker socket", () => {
|
|
15
15
|
expect(flociRunCommand({})).toBe(
|
|
16
|
-
"docker run -d --rm --name chant-floci -p 4566:4566 floci/floci:
|
|
16
|
+
"docker run -d --rm --name chant-floci -p 4566:4566 floci/floci:1.5.34",
|
|
17
17
|
);
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -60,7 +60,7 @@ describe("flociHealthUrl / flociEnv (#704)", () => {
|
|
|
60
60
|
describe("FLOCI_SPEC / FLOCI_EMULATOR — the aws plugin's emulator capability (#920)", () => {
|
|
61
61
|
test("spec pins the Floci container identity the shared lifecycle boots", () => {
|
|
62
62
|
expect(FLOCI_SPEC.name).toBe("chant-floci");
|
|
63
|
-
expect(FLOCI_SPEC.image).toBe("floci/floci:
|
|
63
|
+
expect(FLOCI_SPEC.image).toBe("floci/floci:1.5.34");
|
|
64
64
|
expect(FLOCI_SPEC.containerPort).toBe(4566);
|
|
65
65
|
expect(FLOCI_SPEC.healthPath).toBe("/_localstack/health");
|
|
66
66
|
});
|
|
@@ -2,7 +2,11 @@ import { emulatorLifecycle, type EmulatorSpec, type EmulatorCapability } from "@
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_NAME = "chant-floci";
|
|
4
4
|
const DEFAULT_PORT = 4566;
|
|
5
|
-
|
|
5
|
+
// Pinned, not `:latest` (#1345). An image that moves underneath a passing
|
|
6
|
+
// local suite is the drift a pin exists to stop, and the freshness check in
|
|
7
|
+
// core reports how far behind this is rather than bumping it — the tag moves
|
|
8
|
+
// when a consuming test needs the newer emulator (#808).
|
|
9
|
+
const DEFAULT_IMAGE = "floci/floci:1.5.34";
|
|
6
10
|
const DEFAULT_REGION = "us-east-1";
|
|
7
11
|
const DEFAULT_READY_SERVICE = "cloudformation";
|
|
8
12
|
const DOCKER_SOCK = ["-v", "/var/run/docker.sock:/var/run/docker.sock"] as const;
|
|
@@ -55,6 +59,7 @@ const flociSpecFor = (readyService: string): EmulatorSpec => ({
|
|
|
55
59
|
containerPort: DEFAULT_PORT,
|
|
56
60
|
healthPath: "/_localstack/health",
|
|
57
61
|
ready: (body) => isFlociReady(body, readyService),
|
|
62
|
+
upstream: { repo: "floci-io/floci" },
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
/** The Floci emulator spec — the aws plugin's `emulator` capability (#920). */
|
package/src/plugin.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { CFGenerator } from "./import/generator";
|
|
|
35
35
|
import { parseStackTemplate } from "./import/live-export";
|
|
36
36
|
import { awsCompletions } from "./lsp/completions";
|
|
37
37
|
import { awsHover } from "./lsp/hover";
|
|
38
|
+
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
38
39
|
|
|
39
40
|
/** Re-exported from ./stack-errors so the long-standing import path (and its
|
|
40
41
|
* tests) keep working now that the deep reader shares the classifier. */
|
|
@@ -51,6 +52,9 @@ let warnedOwnership = false;
|
|
|
51
52
|
|
|
52
53
|
export const awsPlugin: LexiconPlugin = {
|
|
53
54
|
name: "aws",
|
|
55
|
+
// The thin read is sourced from describe-stack-resources, which returns no
|
|
56
|
+
// tags — so `owned` on that path can only answer `unknown`, and does (#1348).
|
|
57
|
+
ownershipChannel: { keys: AWS_TAG_OWNERSHIP_KEYS, reads: ["observeResourcesDeep", "exportResources"] },
|
|
54
58
|
serializer: awsSerializer,
|
|
55
59
|
// Local emulator (#920): Floci + the AWS env that redirects the SDK / observe.
|
|
56
60
|
emulator: FLOCI_EMULATOR,
|
|
@@ -72,3 +72,31 @@ describe("awsReferenceCatalog — golden 3-tier VPC", () => {
|
|
|
72
72
|
expect(groups.privA).toEqual(expect.arrayContaining(["svc", "rds"]));
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("an instance is in a VPC (#1432 follow-up)", () => {
|
|
77
|
+
// Subnet, security group and route table all had an "in VPC" rule. The
|
|
78
|
+
// instance did not, so the one kind every estate question is about reached
|
|
79
|
+
// its VPC only through its subnet — two hops, which a one-hop `<-` cannot
|
|
80
|
+
// cross. `describe-instances` returns VpcId directly; nothing had to be
|
|
81
|
+
// inferred.
|
|
82
|
+
const nodes = [
|
|
83
|
+
{ id: "vpc-1", kind: "AWS::EC2::VPC", lexicon: "aws", physicalId: "vpc-1", attrs: { VpcId: "vpc-1" } },
|
|
84
|
+
{ id: "vpc-2", kind: "AWS::EC2::VPC", lexicon: "aws", physicalId: "vpc-2", attrs: { VpcId: "vpc-2" } },
|
|
85
|
+
{ id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", physicalId: "i-1", attrs: { VpcId: "vpc-1", SubnetId: "sub-1" } },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
it("reaches its VPC in one hop", () => {
|
|
89
|
+
const { containmentEdges } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
90
|
+
expect(containmentEdges).toContainEqual({ from: "web", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves an empty VPC reached by nothing — the question being asked", () => {
|
|
94
|
+
const { containmentEdges } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
95
|
+
expect(containmentEdges.some((e) => e.to === "vpc-2")).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not draw it — the VPC stays a boundary", () => {
|
|
99
|
+
const { edges } = reconstructEdges(nodes, awsReferenceCatalog);
|
|
100
|
+
expect(edges.some((e) => e.to === "vpc-1")).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/reference-catalog.ts
CHANGED
|
@@ -35,6 +35,13 @@ export const awsReferenceCatalog: ReferenceCatalog = {
|
|
|
35
35
|
{ from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
36
36
|
{ from: "AWS::EC2::RouteTable", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
37
37
|
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
|
|
38
|
+
// An instance is in a VPC, and `describe-instances` says so directly. The
|
|
39
|
+
// other three kinds that sit in a VPC — subnet, security group, route table
|
|
40
|
+
// — all had this rule; the instance did not, so the estate's own topology
|
|
41
|
+
// was missing its most-asked-about member. Nothing derived it either: the
|
|
42
|
+
// instance reached its VPC only through its subnet, two hops, which a
|
|
43
|
+
// one-hop `<-` cannot cross.
|
|
44
|
+
{ from: "AWS::EC2::Instance", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
38
45
|
{ from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
39
46
|
{ from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
40
47
|
{ from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
AWS_SPEC_PIN,
|
|
4
|
+
PINNED_TYPE_NAMES,
|
|
5
|
+
ACCEPT_ENV,
|
|
6
|
+
specContentDigest,
|
|
7
|
+
specDrift,
|
|
8
|
+
driftMessage,
|
|
9
|
+
assertPinnedSpec,
|
|
10
|
+
} from "./pin";
|
|
11
|
+
|
|
12
|
+
const schema = (typeName: string, extra = "") =>
|
|
13
|
+
Buffer.from(JSON.stringify({ typeName, description: extra }));
|
|
14
|
+
|
|
15
|
+
const archive = (...names: string[]): Map<string, Buffer> =>
|
|
16
|
+
new Map(names.map((n) => [n, schema(n)]));
|
|
17
|
+
|
|
18
|
+
const pinFor = (schemas: Map<string, Buffer>) => ({
|
|
19
|
+
digest: specContentDigest(schemas),
|
|
20
|
+
resources: schemas.size,
|
|
21
|
+
accepted: "2026-01-01",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("specContentDigest (#1390)", () => {
|
|
25
|
+
test("is stable across map insertion order", () => {
|
|
26
|
+
// The zip's file order is not something to depend on.
|
|
27
|
+
const a = archive("AWS::A::One", "AWS::B::Two");
|
|
28
|
+
const b = new Map([...a.entries()].reverse());
|
|
29
|
+
expect(specContentDigest(a)).toBe(specContentDigest(b));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("changes when a schema's bytes change", () => {
|
|
33
|
+
const before = archive("AWS::A::One");
|
|
34
|
+
const after = new Map([["AWS::A::One", schema("AWS::A::One", "now documented")]]);
|
|
35
|
+
expect(specContentDigest(after)).not.toBe(specContentDigest(before));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("changes when a type is added", () => {
|
|
39
|
+
expect(specContentDigest(archive("AWS::A::One", "AWS::B::Two"))).not.toBe(
|
|
40
|
+
specContentDigest(archive("AWS::A::One")),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("changes when a type is removed", () => {
|
|
45
|
+
expect(specContentDigest(archive("AWS::A::One"))).not.toBe(
|
|
46
|
+
specContentDigest(archive("AWS::A::One", "AWS::B::Two")),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an empty archive still digests", () => {
|
|
51
|
+
expect(specContentDigest(new Map())).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("specDrift", () => {
|
|
56
|
+
const pinned = archive("AWS::A::One", "AWS::B::Two");
|
|
57
|
+
const pin = pinFor(pinned);
|
|
58
|
+
const names = new Set(pinned.keys());
|
|
59
|
+
|
|
60
|
+
test("null when the content matches", () => {
|
|
61
|
+
expect(specDrift(pinned, names, pin)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("names what was added", () => {
|
|
65
|
+
const drifted = archive("AWS::A::One", "AWS::B::Two", "AWS::C::Three");
|
|
66
|
+
expect(specDrift(drifted, names, pin)?.added).toEqual(["AWS::C::Three"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("names what was removed", () => {
|
|
70
|
+
expect(specDrift(archive("AWS::A::One"), names, pin)?.removed).toEqual(["AWS::B::Two"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a changed schema drifts with no added or removed types", () => {
|
|
74
|
+
const changed = new Map(pinned);
|
|
75
|
+
changed.set("AWS::A::One", schema("AWS::A::One", "changed"));
|
|
76
|
+
const drift = specDrift(changed, names, pin);
|
|
77
|
+
expect(drift).not.toBeNull();
|
|
78
|
+
expect(drift!.added).toEqual([]);
|
|
79
|
+
expect(drift!.removed).toEqual([]);
|
|
80
|
+
expect(drift!.resources).toBe(pin.resources);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("without a name set it still reports the count", () => {
|
|
84
|
+
const drift = specDrift(archive("AWS::A::One"), undefined, pin);
|
|
85
|
+
expect(drift?.resources).toBe(1);
|
|
86
|
+
expect(drift?.added).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("driftMessage", () => {
|
|
91
|
+
const pinned = archive("AWS::A::One", "AWS::B::Two");
|
|
92
|
+
const pin = pinFor(pinned);
|
|
93
|
+
const names = new Set(pinned.keys());
|
|
94
|
+
|
|
95
|
+
test("states the count delta, not just that a digest differs", () => {
|
|
96
|
+
const drift = specDrift(archive("AWS::A::One", "AWS::B::Two", "AWS::C::Three"), names, pin)!;
|
|
97
|
+
expect(driftMessage(drift, pin)).toContain("+1 against the pin");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("says so when only content changed", () => {
|
|
101
|
+
const changed = new Map(pinned);
|
|
102
|
+
changed.set("AWS::A::One", schema("AWS::A::One", "changed"));
|
|
103
|
+
expect(driftMessage(specDrift(changed, names, pin)!, pin)).toContain("unchanged in count");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("prints the block to paste into the pin", () => {
|
|
107
|
+
const drift = specDrift(archive("AWS::A::One"), names, pin)!;
|
|
108
|
+
const message = driftMessage(drift, pin);
|
|
109
|
+
expect(message).toContain(`digest: "${drift.digest}"`);
|
|
110
|
+
expect(message).toContain("resources: 1,");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("truncates a long list rather than printing hundreds of names", () => {
|
|
114
|
+
const many = archive("AWS::A::One", "AWS::B::Two", ...Array.from({ length: 9 }, (_, i) => `AWS::N::T${i}`));
|
|
115
|
+
expect(driftMessage(specDrift(many, names, pin)!, pin)).toContain("+4 more");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("assertPinnedSpec", () => {
|
|
120
|
+
const pinned = archive("AWS::A::One");
|
|
121
|
+
const pin = pinFor(pinned);
|
|
122
|
+
const names = new Set(pinned.keys());
|
|
123
|
+
|
|
124
|
+
test("passes when the archive matches", () => {
|
|
125
|
+
expect(() => assertPinnedSpec(pinned, { pin, pinnedNames: names, env: {} })).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("refuses a drifted archive — generation does not proceed", () => {
|
|
129
|
+
expect(() => assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), { pin, pinnedNames: names, env: {} }))
|
|
130
|
+
.toThrow(/upstream CloudFormation schema has moved/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the accept env proceeds and reports instead", () => {
|
|
134
|
+
const warnings: string[] = [];
|
|
135
|
+
expect(() =>
|
|
136
|
+
assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), {
|
|
137
|
+
pin,
|
|
138
|
+
pinnedNames: names,
|
|
139
|
+
env: { [ACCEPT_ENV]: "1" },
|
|
140
|
+
warn: (m) => warnings.push(m),
|
|
141
|
+
}),
|
|
142
|
+
).not.toThrow();
|
|
143
|
+
expect(warnings[0]).toContain("AWS::B::Two");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("the committed pin", () => {
|
|
148
|
+
test("records as many type names as it claims resources", () => {
|
|
149
|
+
// A pin bumped without regenerating the name list would make every future
|
|
150
|
+
// drift message wrong about what was added.
|
|
151
|
+
expect(PINNED_TYPE_NAMES.size).toBe(AWS_SPEC_PIN.resources);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("is a sha256 and an ISO date", () => {
|
|
155
|
+
expect(AWS_SPEC_PIN.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
156
|
+
expect(AWS_SPEC_PIN.accepted).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("holds real CloudFormation type names", () => {
|
|
160
|
+
expect(PINNED_TYPE_NAMES.has("AWS::S3::Bucket")).toBe(true);
|
|
161
|
+
expect(PINNED_TYPE_NAMES.has("AWS::IAM::Role")).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* chant #1473 — byte churn is a warning, a moved resource set is still a
|
|
167
|
+
* refusal. The aws lexicon became unpublishable because upstream republishes
|
|
168
|
+
* schemas several times a day and any digest mismatch was fatal.
|
|
169
|
+
*/
|
|
170
|
+
describe("byte churn vs a moved resource set (#1473)", () => {
|
|
171
|
+
const pinned = archive("AWS::S3::Bucket", "AWS::IAM::Role");
|
|
172
|
+
const pin = pinFor(pinned);
|
|
173
|
+
const names = new Set(pinned.keys());
|
|
174
|
+
|
|
175
|
+
function capture(schemas: Map<string, Buffer>) {
|
|
176
|
+
const warnings: string[] = [];
|
|
177
|
+
let threw: Error | undefined;
|
|
178
|
+
try {
|
|
179
|
+
assertPinnedSpec(schemas, { pin, pinnedNames: names, env: {}, warn: (m) => warnings.push(m) });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
threw = err as Error;
|
|
182
|
+
}
|
|
183
|
+
return { warnings, threw };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test("same type set, different bytes — warns and proceeds", () => {
|
|
187
|
+
// AWS editing a description in place. Three digests were observed in one
|
|
188
|
+
// day this way, all with an unchanged resource count.
|
|
189
|
+
const edited = new Map(pinned);
|
|
190
|
+
edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
|
|
191
|
+
|
|
192
|
+
const { warnings, threw } = capture(edited);
|
|
193
|
+
expect(threw).toBeUndefined();
|
|
194
|
+
expect(warnings).toHaveLength(1);
|
|
195
|
+
expect(warnings[0]).toContain("resource set is unchanged");
|
|
196
|
+
expect(warnings[0]).toContain("surface.snapshot.json");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a type removed — still refuses", () => {
|
|
200
|
+
const { threw } = capture(archive("AWS::S3::Bucket"));
|
|
201
|
+
expect(threw?.message).toContain("Generation refuses");
|
|
202
|
+
expect(threw?.message).toContain("removed");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a type added — still refuses", () => {
|
|
206
|
+
const { threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
|
|
207
|
+
expect(threw?.message).toContain("Generation refuses");
|
|
208
|
+
expect(threw?.message).toContain("added");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("an unchanged archive neither warns nor throws", () => {
|
|
212
|
+
const { warnings, threw } = capture(new Map(pinned));
|
|
213
|
+
expect(threw).toBeUndefined();
|
|
214
|
+
expect(warnings).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("with no previous type set to compare, a mismatch stays fatal", () => {
|
|
218
|
+
// `specDrift` reports empty added/removed when it cannot compare, which
|
|
219
|
+
// would otherwise read as "no type moved" and downgrade every mismatch.
|
|
220
|
+
const edited = new Map(pinned);
|
|
221
|
+
edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
|
|
222
|
+
|
|
223
|
+
expect(() =>
|
|
224
|
+
assertPinnedSpec(edited, { pin, pinnedNames: new Set(), env: {}, warn: () => {} }),
|
|
225
|
+
).toThrow(/Generation refuses/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("the accept env still short-circuits both cases", () => {
|
|
229
|
+
const warnings: string[] = [];
|
|
230
|
+
assertPinnedSpec(archive("AWS::S3::Bucket"), {
|
|
231
|
+
pin,
|
|
232
|
+
pinnedNames: names,
|
|
233
|
+
env: { [ACCEPT_ENV]: "1" },
|
|
234
|
+
warn: (m) => warnings.push(m),
|
|
235
|
+
});
|
|
236
|
+
expect(warnings[0]).toContain(ACCEPT_ENV);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the non-fatal message still prints a pastable pin block", () => {
|
|
240
|
+
const edited = new Map(pinned);
|
|
241
|
+
edited.set("AWS::IAM::Role", schema("AWS::IAM::Role", "reworded"));
|
|
242
|
+
const { warnings } = capture(edited);
|
|
243
|
+
expect(warnings[0]).toContain(' digest: "sha256:');
|
|
244
|
+
expect(warnings[0]).toContain(" resources: 2,");
|
|
245
|
+
// The one-off escape hatch is meaningless when nothing is being refused.
|
|
246
|
+
expect(warnings[0]).not.toContain(ACCEPT_ENV);
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/spec/pin.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content pin for the CloudFormation Registry schema (#1390).
|
|
3
|
+
*
|
|
4
|
+
* cfn-lint is pinned to a git tag (`PINNED_VERSIONS.cfnLint`). The registry
|
|
5
|
+
* schema could not be: it is a single "latest" artifact with no version in the
|
|
6
|
+
* path, republished constantly —
|
|
7
|
+
*
|
|
8
|
+
* $ curl -sI .../CloudformationSchema.zip
|
|
9
|
+
* Last-Modified: Mon, 03 Aug 2026 01:29:20 GMT
|
|
10
|
+
*
|
|
11
|
+
* — so `npm run --prefix lexicons/aws prepack`, which CI runs on every build and
|
|
12
|
+
* any local codegen or docs task triggers, resolved to whatever CloudFormation
|
|
13
|
+
* shipped that morning. During the #1312 docs work a docs-only branch twice
|
|
14
|
+
* picked up a resource-count move with nothing in the commit explaining it. The
|
|
15
|
+
* count was only the visible symptom; the same regeneration rewrites generated
|
|
16
|
+
* types and the resource registry.
|
|
17
|
+
*
|
|
18
|
+
* Since there is no version to pin, the pin is over content. Specifically over
|
|
19
|
+
* the **extracted schemas**, not the zip: AWS repackaging the archive changes
|
|
20
|
+
* its `ETag` and its bytes while the schemas are identical, and a pin that fires
|
|
21
|
+
* on that would be noise. Sorted `typeName` → `sha256(schema)`, hashed in order,
|
|
22
|
+
* moves exactly when a schema does.
|
|
23
|
+
*
|
|
24
|
+
* Advisory is not enough here, unlike the emulator image pins (#808). An
|
|
25
|
+
* emulator that drifts fails a test you can see; a spec that drifts silently
|
|
26
|
+
* rewrites committed artifacts in a branch about something else. So a mismatch
|
|
27
|
+
* refuses, and accepting is a deliberate act that lands as its own commit.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash } from "crypto";
|
|
31
|
+
import pinnedTypes from "./pinned-types.json" with { type: "json" };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The resource types the pinned archive contained.
|
|
35
|
+
*
|
|
36
|
+
* Committed beside the digest so accepting a new spec is reviewable as a diff:
|
|
37
|
+
* the PR that moves the pin shows exactly which types AWS added or removed,
|
|
38
|
+
* rather than one opaque hash replacing another. The generated artifacts cannot
|
|
39
|
+
* play that role — `src/generated/` is not committed.
|
|
40
|
+
*/
|
|
41
|
+
export const PINNED_TYPE_NAMES: ReadonlySet<string> = new Set(pinnedTypes as string[]);
|
|
42
|
+
|
|
43
|
+
export interface SpecPin {
|
|
44
|
+
/** `sha256:…` over the sorted typeName → schema content. */
|
|
45
|
+
readonly digest: string;
|
|
46
|
+
/** How many resource types the pinned archive contained. */
|
|
47
|
+
readonly resources: number;
|
|
48
|
+
/** ISO date the pin was accepted, so a diff reads as a decision. */
|
|
49
|
+
readonly accepted: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The accepted upstream spec.
|
|
54
|
+
*
|
|
55
|
+
* To move it: run generation, read the refusal, confirm the delta is one you
|
|
56
|
+
* want, and paste the printed pin here in its own commit.
|
|
57
|
+
*/
|
|
58
|
+
export const AWS_SPEC_PIN: SpecPin = {
|
|
59
|
+
digest: "sha256:f2a0f4f2c0685116fa928a01d889b2aa2a03e4f02cd3257b124cbb184f7e533b",
|
|
60
|
+
resources: 1650,
|
|
61
|
+
accepted: "2026-08-04",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Env var that accepts whatever upstream currently serves, printing the new pin. */
|
|
65
|
+
export const ACCEPT_ENV = "CHANT_ACCEPT_AWS_SPEC";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Digest the extracted schemas. Stable against repackaging; changes when any
|
|
69
|
+
* schema's bytes change, or when a type is added or removed.
|
|
70
|
+
*/
|
|
71
|
+
export function specContentDigest(schemas: ReadonlyMap<string, Buffer>): string {
|
|
72
|
+
const hash = createHash("sha256");
|
|
73
|
+
for (const typeName of [...schemas.keys()].sort()) {
|
|
74
|
+
hash.update(typeName);
|
|
75
|
+
hash.update(createHash("sha256").update(schemas.get(typeName)!).digest());
|
|
76
|
+
}
|
|
77
|
+
return `sha256:${hash.digest("hex")}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What moved between the pinned archive and the one just fetched. */
|
|
81
|
+
export interface SpecDrift {
|
|
82
|
+
digest: string;
|
|
83
|
+
resources: number;
|
|
84
|
+
added: string[];
|
|
85
|
+
removed: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Compare a freshly fetched archive against a pin. `null` when it matches. */
|
|
89
|
+
export function specDrift(
|
|
90
|
+
schemas: ReadonlyMap<string, Buffer>,
|
|
91
|
+
pinnedNames: ReadonlySet<string> | undefined,
|
|
92
|
+
pin: SpecPin = AWS_SPEC_PIN,
|
|
93
|
+
): SpecDrift | null {
|
|
94
|
+
const digest = specContentDigest(schemas);
|
|
95
|
+
if (digest === pin.digest) return null;
|
|
96
|
+
|
|
97
|
+
const names = new Set(schemas.keys());
|
|
98
|
+
return {
|
|
99
|
+
digest,
|
|
100
|
+
resources: names.size,
|
|
101
|
+
// Type names are only known when a caller supplies the previous set;
|
|
102
|
+
// without it the count delta still tells a reader the shape of the change.
|
|
103
|
+
added: pinnedNames ? [...names].filter((n) => !pinnedNames.has(n)).sort() : [],
|
|
104
|
+
removed: pinnedNames ? [...pinnedNames].filter((n) => !names.has(n)).sort() : [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The refusal a mismatch produces, or the acceptance notice under {@link ACCEPT_ENV}. */
|
|
109
|
+
export function driftMessage(
|
|
110
|
+
drift: SpecDrift,
|
|
111
|
+
pin: SpecPin = AWS_SPEC_PIN,
|
|
112
|
+
options: { fatal?: boolean } = {},
|
|
113
|
+
): string {
|
|
114
|
+
const fatal = options.fatal ?? true;
|
|
115
|
+
const delta = drift.resources - pin.resources;
|
|
116
|
+
const countLine =
|
|
117
|
+
delta === 0
|
|
118
|
+
? `${drift.resources} resource types, unchanged in count`
|
|
119
|
+
: `${drift.resources} resource types, ${delta > 0 ? "+" : ""}${delta} against the pin`;
|
|
120
|
+
|
|
121
|
+
const lines = [
|
|
122
|
+
"The upstream CloudFormation schema has moved since the pinned one.",
|
|
123
|
+
"",
|
|
124
|
+
` pinned ${pin.digest} (${pin.resources} resources, accepted ${pin.accepted})`,
|
|
125
|
+
` upstream ${drift.digest} (${countLine})`,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
if (drift.added.length > 0) {
|
|
129
|
+
lines.push(` added ${drift.added.slice(0, 5).join(", ")}${drift.added.length > 5 ? ` (+${drift.added.length - 5} more)` : ""}`);
|
|
130
|
+
}
|
|
131
|
+
if (drift.removed.length > 0) {
|
|
132
|
+
lines.push(` removed ${drift.removed.slice(0, 5).join(", ")}${drift.removed.length > 5 ? ` (+${drift.removed.length - 5} more)` : ""}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (fatal) {
|
|
136
|
+
lines.push(
|
|
137
|
+
"",
|
|
138
|
+
"Generation refuses rather than regenerating against a spec nobody chose:",
|
|
139
|
+
"a docs or codegen task on an unrelated branch would otherwise rewrite the",
|
|
140
|
+
"generated types and resource registry with no commit saying why.",
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
// chant #1473 — the type set is identical, so this is upstream editing
|
|
144
|
+
// schemas in place, not a spec swap. Generation continues; whether it
|
|
145
|
+
// changed anything visible is decided by the surface snapshot.
|
|
146
|
+
lines.push(
|
|
147
|
+
"",
|
|
148
|
+
"The resource set is unchanged, so this is upstream editing schemas in",
|
|
149
|
+
"place rather than a different spec. Generation continues — whether it",
|
|
150
|
+
"changed the published API is decided by surface.snapshot.json, which is",
|
|
151
|
+
"checked against the generated artifacts before anything is packed.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push(
|
|
156
|
+
"",
|
|
157
|
+
"To refresh the pin, confirm the delta is one you want and update",
|
|
158
|
+
"lexicons/aws/src/spec/pin.ts, in its own commit:",
|
|
159
|
+
"",
|
|
160
|
+
` digest: "${drift.digest}",`,
|
|
161
|
+
` resources: ${drift.resources},`,
|
|
162
|
+
` accepted: "<today>",`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (fatal) {
|
|
166
|
+
lines.push("", `Or re-run with ${ACCEPT_ENV}=1 to proceed this once and print the same block.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Refuse a fetched archive whose RESOURCE SET does not match the pin.
|
|
174
|
+
*
|
|
175
|
+
* Under {@link ACCEPT_ENV} it warns with the same detail instead, so the
|
|
176
|
+
* accept-then-paste loop is one command rather than two.
|
|
177
|
+
*
|
|
178
|
+
* ## Why byte drift alone is a warning (chant #1473)
|
|
179
|
+
*
|
|
180
|
+
* This originally threw on any digest mismatch, which made the aws lexicon
|
|
181
|
+
* unpublishable. `prepack` runs `generate`, `generate` fetches from upstream,
|
|
182
|
+
* and CloudFormation republishes individual schemas several times a day: three
|
|
183
|
+
* distinct digests were observed on 2026-08-03 alone, all with an unchanged
|
|
184
|
+
* count of 1650, and two fetches two hours apart differed in 4 of 1650 files.
|
|
185
|
+
* `chant-v0.39.0` published 13 packages and failed on aws for exactly this,
|
|
186
|
+
* with a pin ~18 hours old.
|
|
187
|
+
*
|
|
188
|
+
* A pin that goes stale by itself within hours cannot gate a release: it does
|
|
189
|
+
* not distinguish "someone regenerated against a spec nobody chose" from
|
|
190
|
+
* "AWS edited a description this afternoon".
|
|
191
|
+
*
|
|
192
|
+
* So the two cases are separated:
|
|
193
|
+
*
|
|
194
|
+
* - **The resource set moved** (a type added or removed) — still a refusal.
|
|
195
|
+
* That always changes the published API, and it is the case #1390 was filed
|
|
196
|
+
* about.
|
|
197
|
+
* - **Only bytes moved**, with an identical type set — a warning. Whether it
|
|
198
|
+
* matters is decided downstream by the surface gate in core's
|
|
199
|
+
* `validateLexiconArtifacts`, which compares the generated API against the
|
|
200
|
+
* committed `surface.snapshot.json`. That gate is exact: a byte change that
|
|
201
|
+
* alters the emitted surface fails the build, and one that does not is
|
|
202
|
+
* correctly ignored.
|
|
203
|
+
*
|
|
204
|
+
* The guarantee is unchanged in substance — nothing ships whose surface was
|
|
205
|
+
* not reviewed — and it is now enforced against the thing that actually
|
|
206
|
+
* matters rather than against an archive that is not stable enough to pin.
|
|
207
|
+
*/
|
|
208
|
+
export function assertPinnedSpec(
|
|
209
|
+
schemas: ReadonlyMap<string, Buffer>,
|
|
210
|
+
options: {
|
|
211
|
+
pin?: SpecPin;
|
|
212
|
+
/** Defaults to {@link PINNED_TYPE_NAMES}; overridden in tests. */
|
|
213
|
+
pinnedNames?: ReadonlySet<string>;
|
|
214
|
+
env?: NodeJS.ProcessEnv;
|
|
215
|
+
warn?: (message: string) => void;
|
|
216
|
+
} = {},
|
|
217
|
+
): void {
|
|
218
|
+
const pin = options.pin ?? AWS_SPEC_PIN;
|
|
219
|
+
const pinnedNames = options.pinnedNames ?? PINNED_TYPE_NAMES;
|
|
220
|
+
const drift = specDrift(schemas, pinnedNames, pin);
|
|
221
|
+
if (!drift) return;
|
|
222
|
+
|
|
223
|
+
const env = options.env ?? process.env;
|
|
224
|
+
const warn = options.warn ?? ((m: string) => console.error(m));
|
|
225
|
+
|
|
226
|
+
if (env[ACCEPT_ENV]) {
|
|
227
|
+
warn(driftMessage(drift, pin));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// chant #1473 — an identical type set with different bytes is upstream
|
|
232
|
+
// churn, not an unchosen spec. Reported so a stale pin stays visible, but
|
|
233
|
+
// not fatal; the surface gate decides whether it changed anything.
|
|
234
|
+
//
|
|
235
|
+
// Guarded on actually HAVING a previous type set: `specDrift` reports empty
|
|
236
|
+
// added/removed when it has nothing to compare against, which would
|
|
237
|
+
// otherwise read as "no type moved" and downgrade every mismatch.
|
|
238
|
+
const typeSetKnown = pinnedNames !== undefined && pinnedNames.size > 0;
|
|
239
|
+
const typeSetMoved = drift.added.length > 0 || drift.removed.length > 0;
|
|
240
|
+
|
|
241
|
+
if (typeSetKnown && !typeSetMoved) {
|
|
242
|
+
warn(driftMessage(drift, pin, { fatal: false }));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
throw new Error(driftMessage(drift, pin));
|
|
247
|
+
}
|