@thejoseki/clawform 2.3.1 → 2.3.2
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/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +137 -105
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +163 -150
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/aws/exports.js
CHANGED
|
@@ -1,213 +1,213 @@
|
|
|
1
|
-
// Pre-flight checks on a stack's CloudFormation exports.
|
|
2
|
-
//
|
|
3
|
-
// Two day-2 traps live here, both computed from the LIVE stack's Outputs (which
|
|
4
|
-
// CloudFormation reports with resolved OutputKey + ExportName) so neither needs
|
|
5
|
-
// to resolve template intrinsics:
|
|
6
|
-
//
|
|
7
|
-
// 1. Export-lock (C1) — removing or un-exporting an Output whose export is
|
|
8
|
-
// still imported by another stack. CloudFormation rejects this at execute
|
|
9
|
-
// time with "Export <name> ... in use by <stack>", AFTER partial work and a
|
|
10
|
-
// rollback. We detect it before the changeset so the operator gets the
|
|
11
|
-
// two-step migration guidance instead of a wedged stack. See rules/limits.md.
|
|
12
|
-
//
|
|
13
|
-
// 2. Frozen-export (C2) — a stack whose NAME doesn't match a legacy prefix but
|
|
14
|
-
// that emits legacy-prefixed EXPORT names (e.g. stack `d-cfn-aao-vpc`
|
|
15
|
-
// exporting `OLDNET-VPC`). The name-only legacy gate in the PreToolUse hook
|
|
16
|
-
// can't see this (it's pure, no AWS access); the CLI can, because it has
|
|
17
|
-
// already fetched the live Outputs. See rules/legacy-freeze.md.
|
|
18
|
-
|
|
19
|
-
import { ListImportsCommand } from '@aws-sdk/client-cloudformation';
|
|
20
|
-
import { logger } from '../lib/logger.js';
|
|
21
|
-
import { readEnv, ENV_PREFIX } from '../lib/rename-compat.js';
|
|
22
|
-
|
|
23
|
-
// --- C1: export-lock --------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Live exports that the new template would remove.
|
|
27
|
-
*
|
|
28
|
-
* An export is "removed" when a live Output that carries an ExportName:
|
|
29
|
-
* - has an OutputKey NOT in `templateExportingKeys` (the set of Output keys
|
|
30
|
-
* the new template still exports — from extractExportingOutputKeys()), AND
|
|
31
|
-
* - has an ExportName NOT in `templateLiteralExportNames` (the literal
|
|
32
|
-
* Export.Name strings in the new template — from extractLiteralExportNames()).
|
|
33
|
-
*
|
|
34
|
-
* The second condition kills the OutputKey-rename false positive: renaming the
|
|
35
|
-
* logical key while keeping the same literal Export.Name moves the export, it
|
|
36
|
-
* doesn't remove it, and CloudFormation executes that fine. Intrinsic export
|
|
37
|
-
* names (`!Sub …`) can't be matched offline — an export moved to a key whose
|
|
38
|
-
* name became an intrinsic may still be flagged; ListImports then decides.
|
|
39
|
-
*
|
|
40
|
-
* Catches both "Output deleted entirely" and "Output kept but Export block
|
|
41
|
-
* dropped". NOT caught (documented in rules/limits.md): an in-place rename that
|
|
42
|
-
* keeps the OutputKey but changes Export.Name.
|
|
43
|
-
*
|
|
44
|
-
* @param liveOutputs stack.Outputs from DescribeStacks: [{OutputKey, OutputValue, ExportName}]
|
|
45
|
-
* @param templateExportingKeys Set<string> of OutputKeys the new template exports
|
|
46
|
-
* @param templateLiteralExportNames Set<string> of literal Export.Name strings in the new template
|
|
47
|
-
* @returns Array<{ outputKey, exportName }>
|
|
48
|
-
*/
|
|
49
|
-
export function findRemovedExports(liveOutputs, templateExportingKeys, templateLiteralExportNames = new Set()) {
|
|
50
|
-
const keys = templateExportingKeys instanceof Set ? templateExportingKeys : new Set(templateExportingKeys ?? []);
|
|
51
|
-
const out = [];
|
|
52
|
-
for (const o of liveOutputs ?? []) {
|
|
53
|
-
if (!o || !o.ExportName) continue; // only exported outputs can hit the lock
|
|
54
|
-
if (keys.has(o.OutputKey)) continue; // still exported under the same key
|
|
55
|
-
if (templateLiteralExportNames.has(o.ExportName)) continue; // moved to another key, same export name
|
|
56
|
-
out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Paginate ListImports for one export name. Importers found so far are pushed
|
|
63
|
-
* into `sink` page-by-page, so a caller that catches a mid-pagination error
|
|
64
|
-
* still sees the importers already confirmed — checkExportLock uses this to
|
|
65
|
-
* fail CLOSED on partial proof.
|
|
66
|
-
*
|
|
67
|
-
* Hand-rolled NextToken loop instead of the SDK's paginateListImports on
|
|
68
|
-
* purpose: the generated paginators hard-check `client instanceof
|
|
69
|
-
* CloudFormationClient`, which would break every mocked-client test at this
|
|
70
|
-
* seam (catalog.js/inventory.js can use them because they only ever run
|
|
71
|
-
* against the real client).
|
|
72
|
-
*/
|
|
73
|
-
export async function listImportsFor(client, exportName, sink = []) {
|
|
74
|
-
let nextToken;
|
|
75
|
-
do {
|
|
76
|
-
const resp = await client.send(new ListImportsCommand({ ExportName: exportName, NextToken: nextToken }));
|
|
77
|
-
for (const imp of resp.Imports ?? []) sink.push(imp);
|
|
78
|
-
nextToken = resp.NextToken;
|
|
79
|
-
} while (nextToken);
|
|
80
|
-
return sink;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* For each removed export, ask CloudFormation who still imports it. The
|
|
85
|
-
* per-export checks are independent, so they run concurrently.
|
|
86
|
-
*
|
|
87
|
-
* Failure posture:
|
|
88
|
-
* - A confirmed in-use export → fail CLOSED (caller blocks the deploy): the
|
|
89
|
-
* changeset is guaranteed to fail, so blocking pre-flight strictly helps.
|
|
90
|
-
* This includes PARTIAL proof — importers seen on page 1 before page 2
|
|
91
|
-
* threw still block.
|
|
92
|
-
* - Our own inability to check (ListImports throws with zero importers seen —
|
|
93
|
-
* e.g. missing cloudformation:ListImports permission) → fail OPEN: record
|
|
94
|
-
* the error and let the deploy proceed rather than block on a check we
|
|
95
|
-
* couldn't run. CloudFormation still refuses a genuinely in-use removal.
|
|
96
|
-
*
|
|
97
|
-
* AWS quirk: ListImports throws a ValidationError ("Export ... is not imported
|
|
98
|
-
* by any stack") when an export has zero importers. That's a CLEAN result, not
|
|
99
|
-
* an error — treated as "no importers", not fail-open.
|
|
100
|
-
*
|
|
101
|
-
* @returns { blocked: Array<{exportName, importers}>, unchecked: Array<{exportName, error}> }
|
|
102
|
-
*/
|
|
103
|
-
export async function checkExportLock(client, removedExports) {
|
|
104
|
-
const results = await Promise.all((removedExports ?? []).map(async ({ exportName }) => {
|
|
105
|
-
const importers = [];
|
|
106
|
-
try {
|
|
107
|
-
await listImportsFor(client, exportName, importers);
|
|
108
|
-
return importers.length > 0 ? { blocked: { exportName, importers } } : {};
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (importers.length > 0) return { blocked: { exportName, importers } }; // partial proof → fail closed
|
|
111
|
-
if (isNotImportedError(err)) return {}; // zero importers — safe to remove
|
|
112
|
-
return { unchecked: { exportName, error: err?.message ?? String(err) } };
|
|
113
|
-
}
|
|
114
|
-
}));
|
|
115
|
-
return {
|
|
116
|
-
blocked: results.map((r) => r.blocked).filter(Boolean),
|
|
117
|
-
unchecked: results.map((r) => r.unchecked).filter(Boolean),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function isNotImportedError(err) {
|
|
122
|
-
const msg = err?.message ?? '';
|
|
123
|
-
return err?.name === 'ValidationError' && /is not imported by any stack/i.test(msg);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// --- C2: frozen-export ------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Live export names matching the consumer's legacy prefixes.
|
|
130
|
-
*
|
|
131
|
-
* `legacyRe` must be the ANCHORED regex (buildAnchoredLegacyPrefixRegex) — the
|
|
132
|
-
* prefix has to be the export name's LEADING segment. The hook's unanchored
|
|
133
|
-
* command-line regex would false-positive on legacy tokens mid-name
|
|
134
|
-
* (`p-acme-pri-lmd-payroll-OLDAPP-arn`). Null regex = no gate (fresh consumer).
|
|
135
|
-
*
|
|
136
|
-
* @returns Array<{ outputKey, exportName }>
|
|
137
|
-
*/
|
|
138
|
-
export function findFrozenExports(liveOutputs, legacyRe) {
|
|
139
|
-
if (!legacyRe) return [];
|
|
140
|
-
const out = [];
|
|
141
|
-
for (const o of liveOutputs ?? []) {
|
|
142
|
-
if (!o || !o.ExportName) continue;
|
|
143
|
-
if (legacyRe.test(o.ExportName)) {
|
|
144
|
-
out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Break-glass for the frozen-export gates (mirrors CLAWFORM_SKIP_ACCOUNT_CHECK):
|
|
151
|
-
// intentionally a noisy env var, not a CLI flag. Needed because a frozen-named
|
|
152
|
-
// export, once live, would otherwise also block the remediation deploy that
|
|
153
|
-
// removes it — and unlike the stack-NAME freeze there is no `# FORCE LEGACY`
|
|
154
|
-
// hook path here (the hook never sees export names).
|
|
155
|
-
// Suffix for the lookup, full name for anything a human reads.
|
|
156
|
-
const SKIP_FROZEN_SUFFIX = 'SKIP_FROZEN_EXPORT_CHECK';
|
|
157
|
-
const SKIP_FROZEN_ENV = `${ENV_PREFIX}${SKIP_FROZEN_SUFFIX}`;
|
|
158
|
-
|
|
159
|
-
export function frozenExportCheckSkipped(action) {
|
|
160
|
-
if (readEnv(SKIP_FROZEN_SUFFIX) !== '1') return false;
|
|
161
|
-
logger.warn(
|
|
162
|
-
`${SKIP_FROZEN_ENV}=1 — bypassing the frozen-export gate for this ${action}. ` +
|
|
163
|
-
`Break-glass only; unset to re-enable.`,
|
|
164
|
-
);
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Shared C2 gate for deploy AND rollback: refuse a stack that EMITS
|
|
170
|
-
* legacy-prefixed export names, even when the stack NAME isn't frozen. Closes
|
|
171
|
-
* the "stack-name-only" gap in rules/legacy-freeze.md.
|
|
172
|
-
*
|
|
173
|
-
* @param action 'deploy' | 'recovery' — only changes the refusal wording.
|
|
174
|
-
* Throws on a frozen export. Returns void when clean / skipped via break-glass.
|
|
175
|
-
*/
|
|
176
|
-
export function assertNoFrozenExports({ liveOutputs, legacyRe, stackName, action = 'deploy' } = {}) {
|
|
177
|
-
const frozen = findFrozenExports(liveOutputs, legacyRe);
|
|
178
|
-
if (frozen.length === 0) return;
|
|
179
|
-
if (frozenExportCheckSkipped(action)) return;
|
|
180
|
-
const names = frozen.map((f) => f.exportName).join(', ');
|
|
181
|
-
throw new Error(
|
|
182
|
-
`Stack "${stackName}" emits export(s) matching a legacy prefix (${names}) from your clawform.config.json ` +
|
|
183
|
-
`legacy_prefixes — even though the stack NAME isn't frozen. Clawform treats legacy-named exports as frozen ` +
|
|
184
|
-
`too (rules/legacy-freeze.md). Refusing ${action === 'recovery' ? 'recovery' : 'to deploy'}.\n` +
|
|
185
|
-
`If this is intentional (e.g. retiring the legacy-named export itself), re-run with ` +
|
|
186
|
-
`${SKIP_FROZEN_ENV}=1, or use the raw aws cloudformation CLI.`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* C2 introduction gate: refuse a TEMPLATE whose literal Export.Name strings
|
|
192
|
-
* would CREATE a legacy-prefixed export. Without this, deploy would happily
|
|
193
|
-
* mint e.g. `OLDNET-NewThing` on a clean-named stack (the live-outputs gate only
|
|
194
|
-
* sees exports that already exist) — and the live gate would then freeze the
|
|
195
|
-
* stack against its own tooling. Intrinsic Export.Names can't be checked
|
|
196
|
-
* offline; legacy names are literal in practice.
|
|
197
|
-
*
|
|
198
|
-
* @param templateExportNames Set<string> from extractLiteralExportNames()
|
|
199
|
-
* Throws when a frozen name would be introduced. Honors the same break-glass.
|
|
200
|
-
*/
|
|
201
|
-
export function assertNoFrozenTemplateExports({ templateExportNames, legacyRe, stackName } = {}) {
|
|
202
|
-
if (!legacyRe) return;
|
|
203
|
-
const frozen = [...(templateExportNames ?? [])].filter((n) => legacyRe.test(n));
|
|
204
|
-
if (frozen.length === 0) return;
|
|
205
|
-
if (frozenExportCheckSkipped('deploy')) return;
|
|
206
|
-
throw new Error(
|
|
207
|
-
`Template for stack "${stackName}" would CREATE export(s) named like a legacy prefix (${frozen.join(', ')}) ` +
|
|
208
|
-
`from your clawform.config.json legacy_prefixes. New exports must follow the going-forward convention ` +
|
|
209
|
-
`(rules/cfn-standards.md) — a legacy-prefixed export would immediately freeze this stack against ` +
|
|
210
|
-
`clawform deploy/rollback (rules/legacy-freeze.md). Rename the export. ` +
|
|
211
|
-
`Break-glass: ${SKIP_FROZEN_ENV}=1.`,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
1
|
+
// Pre-flight checks on a stack's CloudFormation exports.
|
|
2
|
+
//
|
|
3
|
+
// Two day-2 traps live here, both computed from the LIVE stack's Outputs (which
|
|
4
|
+
// CloudFormation reports with resolved OutputKey + ExportName) so neither needs
|
|
5
|
+
// to resolve template intrinsics:
|
|
6
|
+
//
|
|
7
|
+
// 1. Export-lock (C1) — removing or un-exporting an Output whose export is
|
|
8
|
+
// still imported by another stack. CloudFormation rejects this at execute
|
|
9
|
+
// time with "Export <name> ... in use by <stack>", AFTER partial work and a
|
|
10
|
+
// rollback. We detect it before the changeset so the operator gets the
|
|
11
|
+
// two-step migration guidance instead of a wedged stack. See rules/limits.md.
|
|
12
|
+
//
|
|
13
|
+
// 2. Frozen-export (C2) — a stack whose NAME doesn't match a legacy prefix but
|
|
14
|
+
// that emits legacy-prefixed EXPORT names (e.g. stack `d-cfn-aao-vpc`
|
|
15
|
+
// exporting `OLDNET-VPC`). The name-only legacy gate in the PreToolUse hook
|
|
16
|
+
// can't see this (it's pure, no AWS access); the CLI can, because it has
|
|
17
|
+
// already fetched the live Outputs. See rules/legacy-freeze.md.
|
|
18
|
+
|
|
19
|
+
import { ListImportsCommand } from '@aws-sdk/client-cloudformation';
|
|
20
|
+
import { logger } from '../lib/logger.js';
|
|
21
|
+
import { readEnv, ENV_PREFIX } from '../lib/rename-compat.js';
|
|
22
|
+
|
|
23
|
+
// --- C1: export-lock --------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Live exports that the new template would remove.
|
|
27
|
+
*
|
|
28
|
+
* An export is "removed" when a live Output that carries an ExportName:
|
|
29
|
+
* - has an OutputKey NOT in `templateExportingKeys` (the set of Output keys
|
|
30
|
+
* the new template still exports — from extractExportingOutputKeys()), AND
|
|
31
|
+
* - has an ExportName NOT in `templateLiteralExportNames` (the literal
|
|
32
|
+
* Export.Name strings in the new template — from extractLiteralExportNames()).
|
|
33
|
+
*
|
|
34
|
+
* The second condition kills the OutputKey-rename false positive: renaming the
|
|
35
|
+
* logical key while keeping the same literal Export.Name moves the export, it
|
|
36
|
+
* doesn't remove it, and CloudFormation executes that fine. Intrinsic export
|
|
37
|
+
* names (`!Sub …`) can't be matched offline — an export moved to a key whose
|
|
38
|
+
* name became an intrinsic may still be flagged; ListImports then decides.
|
|
39
|
+
*
|
|
40
|
+
* Catches both "Output deleted entirely" and "Output kept but Export block
|
|
41
|
+
* dropped". NOT caught (documented in rules/limits.md): an in-place rename that
|
|
42
|
+
* keeps the OutputKey but changes Export.Name.
|
|
43
|
+
*
|
|
44
|
+
* @param liveOutputs stack.Outputs from DescribeStacks: [{OutputKey, OutputValue, ExportName}]
|
|
45
|
+
* @param templateExportingKeys Set<string> of OutputKeys the new template exports
|
|
46
|
+
* @param templateLiteralExportNames Set<string> of literal Export.Name strings in the new template
|
|
47
|
+
* @returns Array<{ outputKey, exportName }>
|
|
48
|
+
*/
|
|
49
|
+
export function findRemovedExports(liveOutputs, templateExportingKeys, templateLiteralExportNames = new Set()) {
|
|
50
|
+
const keys = templateExportingKeys instanceof Set ? templateExportingKeys : new Set(templateExportingKeys ?? []);
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const o of liveOutputs ?? []) {
|
|
53
|
+
if (!o || !o.ExportName) continue; // only exported outputs can hit the lock
|
|
54
|
+
if (keys.has(o.OutputKey)) continue; // still exported under the same key
|
|
55
|
+
if (templateLiteralExportNames.has(o.ExportName)) continue; // moved to another key, same export name
|
|
56
|
+
out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Paginate ListImports for one export name. Importers found so far are pushed
|
|
63
|
+
* into `sink` page-by-page, so a caller that catches a mid-pagination error
|
|
64
|
+
* still sees the importers already confirmed — checkExportLock uses this to
|
|
65
|
+
* fail CLOSED on partial proof.
|
|
66
|
+
*
|
|
67
|
+
* Hand-rolled NextToken loop instead of the SDK's paginateListImports on
|
|
68
|
+
* purpose: the generated paginators hard-check `client instanceof
|
|
69
|
+
* CloudFormationClient`, which would break every mocked-client test at this
|
|
70
|
+
* seam (catalog.js/inventory.js can use them because they only ever run
|
|
71
|
+
* against the real client).
|
|
72
|
+
*/
|
|
73
|
+
export async function listImportsFor(client, exportName, sink = []) {
|
|
74
|
+
let nextToken;
|
|
75
|
+
do {
|
|
76
|
+
const resp = await client.send(new ListImportsCommand({ ExportName: exportName, NextToken: nextToken }));
|
|
77
|
+
for (const imp of resp.Imports ?? []) sink.push(imp);
|
|
78
|
+
nextToken = resp.NextToken;
|
|
79
|
+
} while (nextToken);
|
|
80
|
+
return sink;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* For each removed export, ask CloudFormation who still imports it. The
|
|
85
|
+
* per-export checks are independent, so they run concurrently.
|
|
86
|
+
*
|
|
87
|
+
* Failure posture:
|
|
88
|
+
* - A confirmed in-use export → fail CLOSED (caller blocks the deploy): the
|
|
89
|
+
* changeset is guaranteed to fail, so blocking pre-flight strictly helps.
|
|
90
|
+
* This includes PARTIAL proof — importers seen on page 1 before page 2
|
|
91
|
+
* threw still block.
|
|
92
|
+
* - Our own inability to check (ListImports throws with zero importers seen —
|
|
93
|
+
* e.g. missing cloudformation:ListImports permission) → fail OPEN: record
|
|
94
|
+
* the error and let the deploy proceed rather than block on a check we
|
|
95
|
+
* couldn't run. CloudFormation still refuses a genuinely in-use removal.
|
|
96
|
+
*
|
|
97
|
+
* AWS quirk: ListImports throws a ValidationError ("Export ... is not imported
|
|
98
|
+
* by any stack") when an export has zero importers. That's a CLEAN result, not
|
|
99
|
+
* an error — treated as "no importers", not fail-open.
|
|
100
|
+
*
|
|
101
|
+
* @returns { blocked: Array<{exportName, importers}>, unchecked: Array<{exportName, error}> }
|
|
102
|
+
*/
|
|
103
|
+
export async function checkExportLock(client, removedExports) {
|
|
104
|
+
const results = await Promise.all((removedExports ?? []).map(async ({ exportName }) => {
|
|
105
|
+
const importers = [];
|
|
106
|
+
try {
|
|
107
|
+
await listImportsFor(client, exportName, importers);
|
|
108
|
+
return importers.length > 0 ? { blocked: { exportName, importers } } : {};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (importers.length > 0) return { blocked: { exportName, importers } }; // partial proof → fail closed
|
|
111
|
+
if (isNotImportedError(err)) return {}; // zero importers — safe to remove
|
|
112
|
+
return { unchecked: { exportName, error: err?.message ?? String(err) } };
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
return {
|
|
116
|
+
blocked: results.map((r) => r.blocked).filter(Boolean),
|
|
117
|
+
unchecked: results.map((r) => r.unchecked).filter(Boolean),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isNotImportedError(err) {
|
|
122
|
+
const msg = err?.message ?? '';
|
|
123
|
+
return err?.name === 'ValidationError' && /is not imported by any stack/i.test(msg);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- C2: frozen-export ------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Live export names matching the consumer's legacy prefixes.
|
|
130
|
+
*
|
|
131
|
+
* `legacyRe` must be the ANCHORED regex (buildAnchoredLegacyPrefixRegex) — the
|
|
132
|
+
* prefix has to be the export name's LEADING segment. The hook's unanchored
|
|
133
|
+
* command-line regex would false-positive on legacy tokens mid-name
|
|
134
|
+
* (`p-acme-pri-lmd-payroll-OLDAPP-arn`). Null regex = no gate (fresh consumer).
|
|
135
|
+
*
|
|
136
|
+
* @returns Array<{ outputKey, exportName }>
|
|
137
|
+
*/
|
|
138
|
+
export function findFrozenExports(liveOutputs, legacyRe) {
|
|
139
|
+
if (!legacyRe) return [];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const o of liveOutputs ?? []) {
|
|
142
|
+
if (!o || !o.ExportName) continue;
|
|
143
|
+
if (legacyRe.test(o.ExportName)) {
|
|
144
|
+
out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Break-glass for the frozen-export gates (mirrors CLAWFORM_SKIP_ACCOUNT_CHECK):
|
|
151
|
+
// intentionally a noisy env var, not a CLI flag. Needed because a frozen-named
|
|
152
|
+
// export, once live, would otherwise also block the remediation deploy that
|
|
153
|
+
// removes it — and unlike the stack-NAME freeze there is no `# FORCE LEGACY`
|
|
154
|
+
// hook path here (the hook never sees export names).
|
|
155
|
+
// Suffix for the lookup, full name for anything a human reads.
|
|
156
|
+
const SKIP_FROZEN_SUFFIX = 'SKIP_FROZEN_EXPORT_CHECK';
|
|
157
|
+
const SKIP_FROZEN_ENV = `${ENV_PREFIX}${SKIP_FROZEN_SUFFIX}`;
|
|
158
|
+
|
|
159
|
+
export function frozenExportCheckSkipped(action) {
|
|
160
|
+
if (readEnv(SKIP_FROZEN_SUFFIX) !== '1') return false;
|
|
161
|
+
logger.warn(
|
|
162
|
+
`${SKIP_FROZEN_ENV}=1 — bypassing the frozen-export gate for this ${action}. ` +
|
|
163
|
+
`Break-glass only; unset to re-enable.`,
|
|
164
|
+
);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Shared C2 gate for deploy AND rollback: refuse a stack that EMITS
|
|
170
|
+
* legacy-prefixed export names, even when the stack NAME isn't frozen. Closes
|
|
171
|
+
* the "stack-name-only" gap in rules/legacy-freeze.md.
|
|
172
|
+
*
|
|
173
|
+
* @param action 'deploy' | 'recovery' — only changes the refusal wording.
|
|
174
|
+
* Throws on a frozen export. Returns void when clean / skipped via break-glass.
|
|
175
|
+
*/
|
|
176
|
+
export function assertNoFrozenExports({ liveOutputs, legacyRe, stackName, action = 'deploy' } = {}) {
|
|
177
|
+
const frozen = findFrozenExports(liveOutputs, legacyRe);
|
|
178
|
+
if (frozen.length === 0) return;
|
|
179
|
+
if (frozenExportCheckSkipped(action)) return;
|
|
180
|
+
const names = frozen.map((f) => f.exportName).join(', ');
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Stack "${stackName}" emits export(s) matching a legacy prefix (${names}) from your clawform.config.json ` +
|
|
183
|
+
`legacy_prefixes — even though the stack NAME isn't frozen. Clawform treats legacy-named exports as frozen ` +
|
|
184
|
+
`too (rules/legacy-freeze.md). Refusing ${action === 'recovery' ? 'recovery' : 'to deploy'}.\n` +
|
|
185
|
+
`If this is intentional (e.g. retiring the legacy-named export itself), re-run with ` +
|
|
186
|
+
`${SKIP_FROZEN_ENV}=1, or use the raw aws cloudformation CLI.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* C2 introduction gate: refuse a TEMPLATE whose literal Export.Name strings
|
|
192
|
+
* would CREATE a legacy-prefixed export. Without this, deploy would happily
|
|
193
|
+
* mint e.g. `OLDNET-NewThing` on a clean-named stack (the live-outputs gate only
|
|
194
|
+
* sees exports that already exist) — and the live gate would then freeze the
|
|
195
|
+
* stack against its own tooling. Intrinsic Export.Names can't be checked
|
|
196
|
+
* offline; legacy names are literal in practice.
|
|
197
|
+
*
|
|
198
|
+
* @param templateExportNames Set<string> from extractLiteralExportNames()
|
|
199
|
+
* Throws when a frozen name would be introduced. Honors the same break-glass.
|
|
200
|
+
*/
|
|
201
|
+
export function assertNoFrozenTemplateExports({ templateExportNames, legacyRe, stackName } = {}) {
|
|
202
|
+
if (!legacyRe) return;
|
|
203
|
+
const frozen = [...(templateExportNames ?? [])].filter((n) => legacyRe.test(n));
|
|
204
|
+
if (frozen.length === 0) return;
|
|
205
|
+
if (frozenExportCheckSkipped('deploy')) return;
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Template for stack "${stackName}" would CREATE export(s) named like a legacy prefix (${frozen.join(', ')}) ` +
|
|
208
|
+
`from your clawform.config.json legacy_prefixes. New exports must follow the going-forward convention ` +
|
|
209
|
+
`(rules/cfn-standards.md) — a legacy-prefixed export would immediately freeze this stack against ` +
|
|
210
|
+
`clawform deploy/rollback (rules/legacy-freeze.md). Rename the export. ` +
|
|
211
|
+
`Break-glass: ${SKIP_FROZEN_ENV}=1.`,
|
|
212
|
+
);
|
|
213
|
+
}
|