@intentius/chant-lexicon-aws 0.40.0 → 0.41.3
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/components/capability-plugin.d.ts +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/generated/index.d.ts +37 -3
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +324 -31
- package/dist/spec/fetch.d.ts +27 -1
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/pin.d.ts.map +1 -1
- package/dist/spec/pinned-types.json +3 -0
- package/dist/types/index.d.ts +450 -25
- package/package.json +2 -2
- package/src/components/capability-plugin.test.ts +19 -0
- package/src/components/capability-plugin.ts +5 -2
- package/src/generated/index.d.ts +450 -25
- package/src/generated/index.ts +41 -7
- package/src/generated/lexicon-aws.json +324 -31
- package/src/spec/fetch.test.ts +101 -1
- package/src/spec/fetch.ts +144 -4
- package/src/spec/pin.test.ts +33 -18
- package/src/spec/pin.ts +19 -12
- package/src/spec/pinned-types.json +3 -0
package/src/spec/fetch.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
3
6
|
import { fetchWithCache, extractFromZip, clearCacheFile } from "@intentius/chant/codegen/fetch";
|
|
7
|
+
import { ACCEPT_ENV, AWS_SPEC_PIN, specContentDigest, type SpecPin } from "./pin";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Top-level CloudFormation Registry JSON Schema for a single resource type.
|
|
@@ -67,18 +71,154 @@ const SCHEMA_ZIP_URL = "https://schema.cloudformation.us-east-1.amazonaws.com/Cl
|
|
|
67
71
|
const CACHE_DIR = join(homedir(), ".chant");
|
|
68
72
|
const CACHE_FILE = join(CACHE_DIR, "CloudformationSchema.zip");
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The pinned-spec store (chant #1511): assets on one dedicated GitHub release,
|
|
76
|
+
* named by content digest, in a public repo — so nothing binary ever enters
|
|
77
|
+
* git history, and the download needs no auth anywhere (CI included).
|
|
78
|
+
*
|
|
79
|
+
* Why a store at all: `prepack` regenerates from upstream, and the registry
|
|
80
|
+
* serves a single mutable "latest" artifact — on 2026-08-05 four distinct
|
|
81
|
+
* contents were observed in one day, two *contradicting* each other about the
|
|
82
|
+
* same resources, so the surface gate compared artifacts built from whichever
|
|
83
|
+
* variant that fetch happened to hit. Publishing aws was a retry lottery
|
|
84
|
+
* (v0.41.1 and v0.41.2 both stranded it). The accepted content itself is the
|
|
85
|
+
* only deterministic input; the accept uploads it, every build downloads it
|
|
86
|
+
* by digest and verifies before trusting it.
|
|
87
|
+
*/
|
|
88
|
+
const SPEC_PIN_RELEASE_TAG = "aws-spec-pin";
|
|
89
|
+
const SPEC_PIN_REPO = "INTENTIUS/chant";
|
|
90
|
+
/** Verified-by-digest local copies of pin assets, so repeat builds skip the download. Content-addressed: no TTL, never invalidated. */
|
|
91
|
+
const PIN_CACHE_DIR = join(CACHE_DIR, "spec-pin");
|
|
92
|
+
|
|
93
|
+
/** `<first 12 digest hex>.zip` — the asset (and local cache) name for a pin. */
|
|
94
|
+
export function pinAssetName(pin: SpecPin = AWS_SPEC_PIN): string {
|
|
95
|
+
return `${pin.digest.replace(/^sha256:/, "").slice(0, 12)}.zip`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Public, unauthenticated download URL for a pin's asset. */
|
|
99
|
+
export function pinAssetUrl(pin: SpecPin = AWS_SPEC_PIN): string {
|
|
100
|
+
return `https://github.com/${SPEC_PIN_REPO}/releases/download/${SPEC_PIN_RELEASE_TAG}/${pinAssetName(pin)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Injectable downloader, so tests never reach the network. Returns undefined on any failure — the caller falls back to the live fetch. */
|
|
104
|
+
export type PinAssetDownloader = (url: string) => Promise<Buffer | undefined>;
|
|
105
|
+
|
|
106
|
+
const downloadPinAsset: PinAssetDownloader = async (url) => {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
109
|
+
if (!res.ok) return undefined;
|
|
110
|
+
return Buffer.from(await res.arrayBuffer());
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load and verify the pinned spec: the local verified cache first, then the
|
|
118
|
+
* release asset. Returns undefined when neither is available (offline and
|
|
119
|
+
* cold, or the accept never uploaded) — the caller falls back to the live
|
|
120
|
+
* fetch and the pin's existing advisory drift report. Content that does not
|
|
121
|
+
* digest to the pin throws: a tampered or half-uploaded asset must never
|
|
122
|
+
* silently pass as the accepted spec, wherever it came from.
|
|
123
|
+
*/
|
|
124
|
+
export async function loadPinnedSchemas(
|
|
125
|
+
options: { pin?: SpecPin; cacheDir?: string; download?: PinAssetDownloader } = {},
|
|
126
|
+
): Promise<Map<string, Buffer> | undefined> {
|
|
127
|
+
const pin = options.pin ?? AWS_SPEC_PIN;
|
|
128
|
+
const cacheDir = options.cacheDir ?? PIN_CACHE_DIR;
|
|
129
|
+
const cachePath = join(cacheDir, pinAssetName(pin));
|
|
130
|
+
|
|
131
|
+
const verify = async (zipData: Buffer, source: string): Promise<Map<string, Buffer>> => {
|
|
132
|
+
const schemas = await extractRawSchemas(zipData);
|
|
133
|
+
const digest = specContentDigest(schemas);
|
|
134
|
+
if (digest !== pin.digest) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`pinned spec from ${source} extracts to ${digest}, but the pin declares ${pin.digest} — ` +
|
|
137
|
+
`refusing to build from it. Re-run the accept (${ACCEPT_ENV}=1 npm run generate) to ` +
|
|
138
|
+
`upload the content the pin actually names.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return schemas;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (existsSync(cachePath)) {
|
|
145
|
+
return verify(readFileSync(cachePath), cachePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const url = pinAssetUrl(pin);
|
|
149
|
+
const zipData = await (options.download ?? downloadPinAsset)(url);
|
|
150
|
+
if (zipData === undefined) return undefined;
|
|
151
|
+
const schemas = await verify(zipData, url);
|
|
152
|
+
// Cache only after verification, so the cache can never hold a bad copy.
|
|
153
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
154
|
+
writeFileSync(cachePath, zipData);
|
|
155
|
+
return schemas;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The accept flow's upload half: push the just-accepted zip to the pin
|
|
160
|
+
* release via `gh` (dev-machine path — publishes never upload). Creates the
|
|
161
|
+
* rolling `aws-spec-pin` release on first use. Assets are content-addressed
|
|
162
|
+
* and never overwritten. A missing/unauthenticated `gh` degrades to printing
|
|
163
|
+
* the exact command to run by hand — the accept still completes, and the pin
|
|
164
|
+
* block still prints.
|
|
165
|
+
*/
|
|
166
|
+
async function uploadPinAsset(zipData: Buffer, digest: string): Promise<void> {
|
|
167
|
+
const pin: SpecPin = { ...AWS_SPEC_PIN, digest };
|
|
168
|
+
mkdirSync(PIN_CACHE_DIR, { recursive: true });
|
|
169
|
+
const localPath = join(PIN_CACHE_DIR, pinAssetName(pin));
|
|
170
|
+
writeFileSync(localPath, zipData);
|
|
171
|
+
|
|
172
|
+
const run = promisify(execFile);
|
|
173
|
+
try {
|
|
174
|
+
try {
|
|
175
|
+
await run("gh", ["release", "view", SPEC_PIN_RELEASE_TAG, "--repo", SPEC_PIN_REPO]);
|
|
176
|
+
} catch {
|
|
177
|
+
await run("gh", [
|
|
178
|
+
"release", "create", SPEC_PIN_RELEASE_TAG,
|
|
179
|
+
"--repo", SPEC_PIN_REPO,
|
|
180
|
+
"--title", "aws spec pin assets",
|
|
181
|
+
"--notes", "Content-addressed CloudFormation registry archives, one per accepted spec pin (chant #1511). Uploaded by the accept flow; downloaded and digest-verified by every build. Not a chant release.",
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
await run("gh", ["release", "upload", SPEC_PIN_RELEASE_TAG, localPath, "--repo", SPEC_PIN_REPO]);
|
|
185
|
+
console.error(`Accepted spec uploaded: ${pinAssetUrl(pin)}`);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(
|
|
188
|
+
`Could not upload the accepted spec via gh (${err instanceof Error ? err.message.split("\n")[0] : String(err)}).\n` +
|
|
189
|
+
`Upload it yourself before merging the pin:\n` +
|
|
190
|
+
` gh release upload ${SPEC_PIN_RELEASE_TAG} ${localPath} --repo ${SPEC_PIN_REPO}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
70
195
|
/**
|
|
71
196
|
* Fetch the CloudFormation Registry schema zip and extract per-resource JSON schemas.
|
|
72
197
|
* Returns a Map keyed by typeName (e.g. "AWS::S3::Bucket") to raw JSON bytes.
|
|
73
198
|
*
|
|
74
|
-
*
|
|
199
|
+
* chant #1511 — the pinned release asset is the primary source: when it
|
|
200
|
+
* resolves and digest-verifies, generation uses the content a human accepted
|
|
201
|
+
* rather than whatever upstream variant this fetch happens to hit, so every
|
|
202
|
+
* build (CI, prepack, publish) is deterministic. The live fetch remains for:
|
|
203
|
+
* `force`, the accept flow (`CHANT_ACCEPT_AWS_SPEC=1`, which must sample
|
|
204
|
+
* upstream — and bypasses the 24h cache for the same reason), and when the
|
|
205
|
+
* asset is unreachable. The live path keeps its 24h local cache.
|
|
75
206
|
*/
|
|
76
207
|
export async function fetchSchemaZip(force = false): Promise<Map<string, Buffer>> {
|
|
208
|
+
const accepting = !!process.env[ACCEPT_ENV];
|
|
209
|
+
if (!force && !accepting) {
|
|
210
|
+
const pinned = await loadPinnedSchemas();
|
|
211
|
+
if (pinned) return pinned;
|
|
212
|
+
}
|
|
77
213
|
const zipData = await fetchWithCache(
|
|
78
214
|
{ url: SCHEMA_ZIP_URL, cacheFile: CACHE_FILE },
|
|
79
|
-
force,
|
|
215
|
+
force || accepting,
|
|
80
216
|
);
|
|
81
|
-
|
|
217
|
+
const schemas = await extractRawSchemas(zipData);
|
|
218
|
+
if (accepting) {
|
|
219
|
+
await uploadPinAsset(zipData, specContentDigest(schemas));
|
|
220
|
+
}
|
|
221
|
+
return schemas;
|
|
82
222
|
}
|
|
83
223
|
|
|
84
224
|
/**
|
package/src/spec/pin.test.ts
CHANGED
|
@@ -125,9 +125,16 @@ describe("assertPinnedSpec", () => {
|
|
|
125
125
|
expect(() => assertPinnedSpec(pinned, { pin, pinnedNames: names, env: {} })).not.toThrow();
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
test("
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
test("reports a drifted archive — generation proceeds (#1473)", () => {
|
|
129
|
+
// Was a throw. Enforcement moved to the release-time surface gate, so a
|
|
130
|
+
// moving upstream can no longer redden an unrelated PR.
|
|
131
|
+
const warnings: string[] = [];
|
|
132
|
+
expect(() =>
|
|
133
|
+
assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), {
|
|
134
|
+
pin, pinnedNames: names, env: {}, warn: (m) => warnings.push(m),
|
|
135
|
+
}),
|
|
136
|
+
).not.toThrow();
|
|
137
|
+
expect(warnings[0]).toMatch(/upstream CloudFormation schema has moved/);
|
|
131
138
|
});
|
|
132
139
|
|
|
133
140
|
test("the accept env proceeds and reports instead", () => {
|
|
@@ -196,16 +203,26 @@ describe("byte churn vs a moved resource set (#1473)", () => {
|
|
|
196
203
|
expect(warnings[0]).toContain("surface.snapshot.json");
|
|
197
204
|
});
|
|
198
205
|
|
|
199
|
-
test("a type removed —
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
test("a type removed — reports loudly, does not throw", () => {
|
|
207
|
+
// Enforcement is the release-time surface gate; a removed type always
|
|
208
|
+
// shows up there. Throwing here made every aws PR hostage to upstream.
|
|
209
|
+
const { warnings, threw } = capture(archive("AWS::S3::Bucket"));
|
|
210
|
+
expect(threw).toBeUndefined();
|
|
211
|
+
expect(warnings[0]).toContain("removed");
|
|
212
|
+
expect(warnings[0]).toContain("Generation refuses");
|
|
203
213
|
});
|
|
204
214
|
|
|
205
|
-
test("a type added —
|
|
206
|
-
const { threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
|
|
207
|
-
expect(threw
|
|
208
|
-
expect(
|
|
215
|
+
test("a type added — reports loudly, does not throw", () => {
|
|
216
|
+
const { warnings, threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
|
|
217
|
+
expect(threw).toBeUndefined();
|
|
218
|
+
expect(warnings[0]).toContain("added");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a moved type set is reported more urgently than byte churn", () => {
|
|
222
|
+
const edited = new Map(pinned);
|
|
223
|
+
edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
|
|
224
|
+
expect(capture(edited).warnings[0]).toContain("resource set is unchanged");
|
|
225
|
+
expect(capture(archive("AWS::S3::Bucket")).warnings[0]).not.toContain("resource set is unchanged");
|
|
209
226
|
});
|
|
210
227
|
|
|
211
228
|
test("an unchanged archive neither warns nor throws", () => {
|
|
@@ -214,15 +231,13 @@ describe("byte churn vs a moved resource set (#1473)", () => {
|
|
|
214
231
|
expect(warnings).toEqual([]);
|
|
215
232
|
});
|
|
216
233
|
|
|
217
|
-
test("
|
|
218
|
-
//
|
|
219
|
-
//
|
|
234
|
+
test("generation is never blocked by the pin, whatever moved", () => {
|
|
235
|
+
// The invariant that matters now: `generate` always completes. Whether the
|
|
236
|
+
// result may SHIP is decided by the surface gate in validate.
|
|
220
237
|
const edited = new Map(pinned);
|
|
221
238
|
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/);
|
|
239
|
+
expect(() => assertPinnedSpec(edited, { pin, pinnedNames: new Set(), env: {}, warn: () => {} })).not.toThrow();
|
|
240
|
+
expect(() => assertPinnedSpec(archive("AWS::X::Y"), { pin, pinnedNames: names, env: {}, warn: () => {} })).not.toThrow();
|
|
226
241
|
});
|
|
227
242
|
|
|
228
243
|
test("the accept env still short-circuits both cases", () => {
|
package/src/spec/pin.ts
CHANGED
|
@@ -56,9 +56,9 @@ export interface SpecPin {
|
|
|
56
56
|
* want, and paste the printed pin here in its own commit.
|
|
57
57
|
*/
|
|
58
58
|
export const AWS_SPEC_PIN: SpecPin = {
|
|
59
|
-
digest: "sha256:
|
|
60
|
-
resources:
|
|
61
|
-
accepted: "2026-08-
|
|
59
|
+
digest: "sha256:f05366bcba3160e5992f4ae312286e6f06a87dcbef425b29b39259beadd42ce0",
|
|
60
|
+
resources: 1653,
|
|
61
|
+
accepted: "2026-08-05",
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
/** Env var that accepts whatever upstream currently serves, printing the new pin. */
|
|
@@ -235,13 +235,20 @@ export function assertPinnedSpec(
|
|
|
235
235
|
// Guarded on actually HAVING a previous type set: `specDrift` reports empty
|
|
236
236
|
// added/removed when it has nothing to compare against, which would
|
|
237
237
|
// otherwise read as "no type moved" and downgrade every mismatch.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
238
|
+
// chant #1473 — the pin reports, it does not gate.
|
|
239
|
+
//
|
|
240
|
+
// Refusing here made every aws PR hostage to CloudFormation: the archive
|
|
241
|
+
// gains and edits types through the day, and `generate` runs on every CI
|
|
242
|
+
// job, so an unrelated change goes red the moment upstream moves. Both the
|
|
243
|
+
// 0.39.0 and 0.40.1 releases died this way, and a PR *accepting* the drift
|
|
244
|
+
// was itself refused by a newer drift that arrived while its CI queued.
|
|
245
|
+
//
|
|
246
|
+
// Enforcement lives where the consequence is: core's
|
|
247
|
+
// `validateLexiconArtifacts` compares the generated API against the reviewed
|
|
248
|
+
// `surface.snapshot.json` and, armed by CHANT_RELEASE_GATE, refuses to
|
|
249
|
+
// publish a surface nobody reviewed. A new or removed type always shows up
|
|
250
|
+
// there, so nothing is lost by reporting rather than throwing here — while a
|
|
251
|
+
// description edit, which changes the digest and no declaration, stops
|
|
252
|
+
// costing a red build.
|
|
253
|
+
warn(driftMessage(drift, pin, { fatal: drift.added.length > 0 || drift.removed.length > 0 }));
|
|
247
254
|
}
|
|
@@ -968,6 +968,7 @@
|
|
|
968
968
|
"AWS::Lex::ResourcePolicy",
|
|
969
969
|
"AWS::LicenseManager::Grant",
|
|
970
970
|
"AWS::LicenseManager::License",
|
|
971
|
+
"AWS::LicenseManager::LicenseAssetRuleSet",
|
|
971
972
|
"AWS::Lightsail::Alarm",
|
|
972
973
|
"AWS::Lightsail::Bucket",
|
|
973
974
|
"AWS::Lightsail::Certificate",
|
|
@@ -1236,6 +1237,7 @@
|
|
|
1236
1237
|
"AWS::QuickSight::Template",
|
|
1237
1238
|
"AWS::QuickSight::Theme",
|
|
1238
1239
|
"AWS::QuickSight::Topic",
|
|
1240
|
+
"AWS::QuickSight::TopicV2",
|
|
1239
1241
|
"AWS::QuickSight::VPCConnection",
|
|
1240
1242
|
"AWS::RAM::Permission",
|
|
1241
1243
|
"AWS::RAM::ResourceShare",
|
|
@@ -1273,6 +1275,7 @@
|
|
|
1273
1275
|
"AWS::Redshift::EventSubscription",
|
|
1274
1276
|
"AWS::Redshift::Integration",
|
|
1275
1277
|
"AWS::Redshift::ScheduledAction",
|
|
1278
|
+
"AWS::Redshift::SnapshotSchedule",
|
|
1276
1279
|
"AWS::RedshiftServerless::Namespace",
|
|
1277
1280
|
"AWS::RedshiftServerless::Snapshot",
|
|
1278
1281
|
"AWS::RedshiftServerless::Workgroup",
|