@thejoseki/clawform 0.0.1 โ 2.2.1
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/LICENSE +95 -4
- package/README.md +93 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +150 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- package/src/lib/watermark.js +65 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateChangeSetCommand,
|
|
3
|
+
DescribeChangeSetCommand,
|
|
4
|
+
DeleteChangeSetCommand,
|
|
5
|
+
DescribeStacksCommand,
|
|
6
|
+
DescribeStackEventsCommand,
|
|
7
|
+
ExecuteChangeSetCommand,
|
|
8
|
+
} from '@aws-sdk/client-cloudformation';
|
|
9
|
+
import { isDataResource } from '../lib/constants.js';
|
|
10
|
+
|
|
11
|
+
const DEPLOYABLE_FROM_UPDATE = new Set([
|
|
12
|
+
'CREATE_COMPLETE',
|
|
13
|
+
'UPDATE_COMPLETE',
|
|
14
|
+
'UPDATE_ROLLBACK_COMPLETE',
|
|
15
|
+
'IMPORT_COMPLETE',
|
|
16
|
+
'IMPORT_ROLLBACK_COMPLETE',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const TERMINAL_EXECUTE_STATUSES = new Set([
|
|
20
|
+
'CREATE_COMPLETE',
|
|
21
|
+
'UPDATE_COMPLETE',
|
|
22
|
+
'ROLLBACK_COMPLETE',
|
|
23
|
+
'UPDATE_ROLLBACK_COMPLETE',
|
|
24
|
+
'CREATE_FAILED',
|
|
25
|
+
'ROLLBACK_FAILED',
|
|
26
|
+
'UPDATE_ROLLBACK_FAILED',
|
|
27
|
+
'UPDATE_FAILED',
|
|
28
|
+
'DELETE_COMPLETE',
|
|
29
|
+
'DELETE_FAILED',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const EMPTY_CHANGESET_RE = /didn'?t contain changes|no updates are to be performed/i;
|
|
33
|
+
|
|
34
|
+
export async function stackStatus(client, stackName) {
|
|
35
|
+
try {
|
|
36
|
+
const out = await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
37
|
+
const stack = out.Stacks?.[0];
|
|
38
|
+
if (!stack) return { exists: false };
|
|
39
|
+
return { exists: true, status: stack.StackStatus, stack };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.name === 'ValidationError' && /does not exist/i.test(err.message ?? '')) {
|
|
42
|
+
return { exists: false };
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decideChangeSetType({ exists, status }) {
|
|
49
|
+
if (!exists) return 'CREATE';
|
|
50
|
+
if (status === 'REVIEW_IN_PROGRESS') return 'CREATE';
|
|
51
|
+
if (DEPLOYABLE_FROM_UPDATE.has(status)) return 'UPDATE';
|
|
52
|
+
if (/_IN_PROGRESS$/.test(status)) {
|
|
53
|
+
throw new Error(`Stack is ${status}; another operation is running. Wait or check the console.`);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Stack is in ${status}; use /rollback to recover before deploying.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function changeSetName(prefix = 'claude') {
|
|
59
|
+
const d = new Date();
|
|
60
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
61
|
+
const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
|
|
62
|
+
return `${prefix}-${stamp}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function createAndWait(client, {
|
|
66
|
+
stackName,
|
|
67
|
+
changeSetType,
|
|
68
|
+
templateBody,
|
|
69
|
+
parameters = [],
|
|
70
|
+
tags = [],
|
|
71
|
+
capabilities = [],
|
|
72
|
+
name = changeSetName(),
|
|
73
|
+
pollIntervalMs = 2000,
|
|
74
|
+
timeoutMs = 5 * 60 * 1000,
|
|
75
|
+
}) {
|
|
76
|
+
await client.send(new CreateChangeSetCommand({
|
|
77
|
+
StackName: stackName,
|
|
78
|
+
ChangeSetName: name,
|
|
79
|
+
ChangeSetType: changeSetType,
|
|
80
|
+
TemplateBody: templateBody,
|
|
81
|
+
Parameters: parameters,
|
|
82
|
+
Tags: tags,
|
|
83
|
+
Capabilities: capabilities,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
while (true) {
|
|
88
|
+
const desc = await describeFull(client, stackName, name);
|
|
89
|
+
if (desc.Status === 'CREATE_COMPLETE') {
|
|
90
|
+
return {
|
|
91
|
+
name,
|
|
92
|
+
id: desc.ChangeSetId,
|
|
93
|
+
isEmpty: false,
|
|
94
|
+
changes: desc.Changes ?? [],
|
|
95
|
+
statusReason: desc.StatusReason ?? null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (desc.Status === 'FAILED') {
|
|
99
|
+
if (EMPTY_CHANGESET_RE.test(desc.StatusReason ?? '')) {
|
|
100
|
+
await safeDelete(client, stackName, name);
|
|
101
|
+
return { name, id: desc.ChangeSetId, isEmpty: true, changes: [], statusReason: desc.StatusReason };
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Change-set creation failed: ${desc.StatusReason ?? '(no reason)'}`);
|
|
104
|
+
}
|
|
105
|
+
if (Date.now() - started > timeoutMs) {
|
|
106
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for change-set ${name} to be ready (last status: ${desc.Status}).`);
|
|
107
|
+
}
|
|
108
|
+
await sleep(pollIntervalMs);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function describeFull(client, stackName, name) {
|
|
113
|
+
const all = [];
|
|
114
|
+
let nextToken;
|
|
115
|
+
let first;
|
|
116
|
+
do {
|
|
117
|
+
const out = await client.send(new DescribeChangeSetCommand({
|
|
118
|
+
StackName: stackName,
|
|
119
|
+
ChangeSetName: name,
|
|
120
|
+
NextToken: nextToken,
|
|
121
|
+
}));
|
|
122
|
+
if (!first) first = out;
|
|
123
|
+
for (const c of out.Changes ?? []) all.push(c);
|
|
124
|
+
nextToken = out.NextToken;
|
|
125
|
+
} while (nextToken);
|
|
126
|
+
return { ...first, Changes: all };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function safeDelete(client, stackName, name) {
|
|
130
|
+
try {
|
|
131
|
+
await client.send(new DeleteChangeSetCommand({ StackName: stackName, ChangeSetName: name }));
|
|
132
|
+
} catch {
|
|
133
|
+
// Empty change-sets sometimes auto-clean; ignore deletion errors here.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function classifyChanges(changes) {
|
|
138
|
+
const buckets = { add: [], modify: [], replace: [], remove: [], dataResourceReplace: [] };
|
|
139
|
+
for (const c of changes) {
|
|
140
|
+
if (c.Type !== 'Resource' || !c.ResourceChange) continue;
|
|
141
|
+
const rc = c.ResourceChange;
|
|
142
|
+
const entry = {
|
|
143
|
+
logicalId: rc.LogicalResourceId,
|
|
144
|
+
physicalId: rc.PhysicalResourceId ?? null,
|
|
145
|
+
resourceType: rc.ResourceType,
|
|
146
|
+
replacement: rc.Replacement,
|
|
147
|
+
scope: rc.Scope ?? [],
|
|
148
|
+
detailsTargets: (rc.Details ?? []).map((d) => d.Target?.Attribute).filter(Boolean),
|
|
149
|
+
};
|
|
150
|
+
switch (rc.Action) {
|
|
151
|
+
case 'Add':
|
|
152
|
+
buckets.add.push(entry);
|
|
153
|
+
break;
|
|
154
|
+
case 'Remove':
|
|
155
|
+
buckets.remove.push(entry);
|
|
156
|
+
break;
|
|
157
|
+
case 'Modify':
|
|
158
|
+
if (rc.Replacement === 'True' || rc.Replacement === 'Conditional') {
|
|
159
|
+
buckets.replace.push(entry);
|
|
160
|
+
if (isDataResource(rc.ResourceType)) buckets.dataResourceReplace.push(entry);
|
|
161
|
+
} else {
|
|
162
|
+
buckets.modify.push(entry);
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
buckets.modify.push(entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return buckets;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function executeAndPoll(client, {
|
|
173
|
+
stackName,
|
|
174
|
+
changeSetName: name,
|
|
175
|
+
onEvent = () => {},
|
|
176
|
+
pollIntervalMs = 3000,
|
|
177
|
+
timeoutMs = 60 * 60 * 1000,
|
|
178
|
+
}) {
|
|
179
|
+
await client.send(new ExecuteChangeSetCommand({ StackName: stackName, ChangeSetName: name }));
|
|
180
|
+
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const started = Date.now();
|
|
183
|
+
while (true) {
|
|
184
|
+
const events = await fetchEvents(client, stackName, seen);
|
|
185
|
+
for (const ev of events) {
|
|
186
|
+
if (seen.has(ev.EventId)) continue;
|
|
187
|
+
seen.add(ev.EventId);
|
|
188
|
+
onEvent(ev);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { stack } = await stackStatus(client, stackName);
|
|
192
|
+
const status = stack?.StackStatus;
|
|
193
|
+
if (status && TERMINAL_EXECUTE_STATUSES.has(status)) {
|
|
194
|
+
return { stackStatus: status, stack };
|
|
195
|
+
}
|
|
196
|
+
if (Date.now() - started > timeoutMs) {
|
|
197
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 60000)}m waiting for stack ${stackName} (last status: ${status}).`);
|
|
198
|
+
}
|
|
199
|
+
await sleep(pollIntervalMs);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Paginate DescribeStackEvents until we encounter an event we've already narrated.
|
|
204
|
+
// DescribeStackEvents returns newest-first within a page AND pages are newest-first,
|
|
205
|
+
// so once we hit a known EventId every older event is also already in `seen` โ safe
|
|
206
|
+
// to stop. Without this, a poll window emitting >100 events (large stacks, nested-
|
|
207
|
+
// stack expansion, rollback fan-out) silently drops the older end off page 1 and
|
|
208
|
+
// onEvent is never called for them โ the operator misses CREATE_FAILED rows that
|
|
209
|
+
// scrolled off between polls. (Tracked as merged_bug_008 from ultrareview.)
|
|
210
|
+
export async function fetchEvents(client, stackName, seen = new Set()) {
|
|
211
|
+
const events = [];
|
|
212
|
+
let nextToken;
|
|
213
|
+
do {
|
|
214
|
+
const resp = await client.send(new DescribeStackEventsCommand({
|
|
215
|
+
StackName: stackName,
|
|
216
|
+
NextToken: nextToken,
|
|
217
|
+
}));
|
|
218
|
+
let stopHere = false;
|
|
219
|
+
for (const ev of resp.StackEvents ?? []) {
|
|
220
|
+
if (seen.has(ev.EventId)) { stopHere = true; break; }
|
|
221
|
+
events.push(ev);
|
|
222
|
+
}
|
|
223
|
+
if (stopHere) break;
|
|
224
|
+
nextToken = resp.NextToken;
|
|
225
|
+
} while (nextToken);
|
|
226
|
+
return events.reverse(); // present oldest-first for narration
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function sleep(ms) {
|
|
230
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
231
|
+
}
|
package/src/aws/drift.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DetectStackDriftCommand,
|
|
3
|
+
DescribeStackDriftDetectionStatusCommand,
|
|
4
|
+
DescribeStackResourceDriftsCommand,
|
|
5
|
+
} from '@aws-sdk/client-cloudformation';
|
|
6
|
+
|
|
7
|
+
const TERMINAL = new Set(['DETECTION_COMPLETE', 'DETECTION_FAILED']);
|
|
8
|
+
|
|
9
|
+
export async function detectAndWait(client, {
|
|
10
|
+
stackName,
|
|
11
|
+
pollIntervalMs = 3000,
|
|
12
|
+
timeoutMs = 10 * 60 * 1000,
|
|
13
|
+
}) {
|
|
14
|
+
const { StackDriftDetectionId } = await client.send(new DetectStackDriftCommand({ StackName: stackName }));
|
|
15
|
+
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
while (true) {
|
|
18
|
+
const out = await client.send(new DescribeStackDriftDetectionStatusCommand({ StackDriftDetectionId }));
|
|
19
|
+
if (TERMINAL.has(out.DetectionStatus)) {
|
|
20
|
+
if (out.DetectionStatus === 'DETECTION_FAILED') {
|
|
21
|
+
throw new Error(`Drift detection failed for ${stackName}: ${out.DetectionStatusReason ?? '(no reason)'}`);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
stackDriftStatus: out.StackDriftStatus,
|
|
25
|
+
driftedResourceCount: out.DriftedStackResourceCount ?? 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (Date.now() - started > timeoutMs) {
|
|
29
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 60000)}m waiting for drift detection on ${stackName}.`);
|
|
30
|
+
}
|
|
31
|
+
await sleep(pollIntervalMs);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function listResourceDrifts(client, stackName, statusFilter = ['MODIFIED', 'DELETED']) {
|
|
36
|
+
const drifts = [];
|
|
37
|
+
let nextToken;
|
|
38
|
+
do {
|
|
39
|
+
const out = await client.send(new DescribeStackResourceDriftsCommand({
|
|
40
|
+
StackName: stackName,
|
|
41
|
+
StackResourceDriftStatusFilters: statusFilter,
|
|
42
|
+
NextToken: nextToken,
|
|
43
|
+
}));
|
|
44
|
+
for (const d of out.StackResourceDrifts ?? []) {
|
|
45
|
+
drifts.push({
|
|
46
|
+
logicalId: d.LogicalResourceId,
|
|
47
|
+
resourceType: d.ResourceType,
|
|
48
|
+
driftStatus: d.StackResourceDriftStatus,
|
|
49
|
+
propertyDifferences: d.PropertyDifferences ?? [],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
nextToken = out.NextToken;
|
|
53
|
+
} while (nextToken);
|
|
54
|
+
return drifts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sleep(ms) {
|
|
58
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { paginateListStacks } from '@aws-sdk/client-cloudformation';
|
|
2
|
+
|
|
3
|
+
export const STALE_DAYS = 180;
|
|
4
|
+
|
|
5
|
+
const LIVE_STATUSES = [
|
|
6
|
+
'CREATE_COMPLETE',
|
|
7
|
+
'UPDATE_COMPLETE',
|
|
8
|
+
'UPDATE_ROLLBACK_COMPLETE',
|
|
9
|
+
'IMPORT_COMPLETE',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const hasLegacyPrefix = (name, prefixes) => prefixes.some((p) => name.startsWith(p + '-'));
|
|
13
|
+
|
|
14
|
+
const SERVERLESS_DESC_RE = /Serverless application/i;
|
|
15
|
+
|
|
16
|
+
export async function fetchStacks(client) {
|
|
17
|
+
const stacks = [];
|
|
18
|
+
for await (const page of paginateListStacks(
|
|
19
|
+
{ client },
|
|
20
|
+
{ StackStatusFilter: LIVE_STATUSES },
|
|
21
|
+
)) {
|
|
22
|
+
for (const s of page.StackSummaries ?? []) {
|
|
23
|
+
stacks.push({
|
|
24
|
+
name: s.StackName,
|
|
25
|
+
status: s.StackStatus,
|
|
26
|
+
templateDescription: s.TemplateDescription,
|
|
27
|
+
creationTime: s.CreationTime,
|
|
28
|
+
lastUpdatedTime: s.LastUpdatedTime,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return stacks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function classifyStacks(stacks, now = new Date(), legacyPrefixes = []) {
|
|
36
|
+
// legacyPrefixes comes from the consumer's clawform.config.json. Empty = a fresh
|
|
37
|
+
// consumer with no legacy stacks; nothing gets routed to the `legacy` bucket.
|
|
38
|
+
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
39
|
+
const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
40
|
+
for (const s of stacks) {
|
|
41
|
+
if (hasLegacyPrefix(s.name, legacyPrefixes)) {
|
|
42
|
+
result.legacy.push(s);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (SERVERLESS_DESC_RE.test(s.templateDescription ?? '')) {
|
|
46
|
+
result.serverless.push(s);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const lastTouch = s.lastUpdatedTime ?? s.creationTime;
|
|
50
|
+
const ageMs = lastTouch ? now.getTime() - new Date(lastTouch).getTime() : 0;
|
|
51
|
+
if (ageMs > staleMs) result.stale.push(s);
|
|
52
|
+
else result.active.push(s);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fmtDate(d) {
|
|
58
|
+
if (!d) return '';
|
|
59
|
+
return new Date(d).toISOString().slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function escapePipe(s) {
|
|
63
|
+
return String(s ?? '').replace(/\|/g, '\\|');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderSection(lines, title, items, headers, fmtRow) {
|
|
67
|
+
lines.push(`## ${title} (${items.length})`, '');
|
|
68
|
+
if (items.length === 0) {
|
|
69
|
+
lines.push('_None_', '');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
lines.push('| ' + headers.join(' | ') + ' |');
|
|
73
|
+
lines.push('|' + headers.map(() => '---').join('|') + '|');
|
|
74
|
+
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
for (const it of sorted) {
|
|
76
|
+
lines.push('| ' + fmtRow(it).map((v) => `\`${escapePipe(v)}\``).join(' | ') + ' |');
|
|
77
|
+
}
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderInventory(classified, meta) {
|
|
82
|
+
const lines = [
|
|
83
|
+
'# CloudFormation Stacks Inventory',
|
|
84
|
+
'',
|
|
85
|
+
`> Generated by \`clawform sync\` at ${meta.generatedAt}`,
|
|
86
|
+
`> Account: ${meta.account} ยท Region: ${meta.region}`,
|
|
87
|
+
'',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
renderSection(
|
|
91
|
+
lines,
|
|
92
|
+
'๐ Legacy (refuse modify โ see rules/legacy-freeze.md)',
|
|
93
|
+
classified.legacy,
|
|
94
|
+
['Stack', 'Status', 'Last update'],
|
|
95
|
+
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
renderSection(
|
|
99
|
+
lines,
|
|
100
|
+
'๐ ๏ธ Serverless Framework (managed by `sls` tool โ modify via serverless.yml)',
|
|
101
|
+
classified.serverless,
|
|
102
|
+
['Stack', 'Status'],
|
|
103
|
+
(s) => [s.name, s.status],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
renderSection(
|
|
107
|
+
lines,
|
|
108
|
+
'โ
Active (Clawform-modifiable)',
|
|
109
|
+
classified.active,
|
|
110
|
+
['Stack', 'Status', 'Last update'],
|
|
111
|
+
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
renderSection(
|
|
115
|
+
lines,
|
|
116
|
+
`๐ค Stale (โฅ${STALE_DAYS} days no update โ review/decommission)`,
|
|
117
|
+
classified.stale,
|
|
118
|
+
['Stack', 'Last update'],
|
|
119
|
+
(s) => [s.name, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SECTION_KEY_BY_TITLE_FRAGMENT = [
|
|
126
|
+
['Legacy', 'legacy'],
|
|
127
|
+
['Serverless', 'serverless'],
|
|
128
|
+
['Active', 'active'],
|
|
129
|
+
['Stale', 'stale'],
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
function sectionKeyFromTitle(title) {
|
|
133
|
+
const hit = SECTION_KEY_BY_TITLE_FRAGMENT.find(([fragment]) => title.includes(fragment));
|
|
134
|
+
return hit ? hit[1] : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read-back counterpart to renderInventory โ recovers the stack list from a
|
|
138
|
+
// committed .clawform/catalog/stacks-inventory.md without a live AWS call. Used by the
|
|
139
|
+
// project-audit workflow (Phase 3), which runs with no network access.
|
|
140
|
+
export function parseInventory(markdown) {
|
|
141
|
+
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
142
|
+
let currentKey = null;
|
|
143
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
144
|
+
const heading = line.match(/^## (.+?) \(\d+\)$/);
|
|
145
|
+
if (heading) {
|
|
146
|
+
currentKey = sectionKeyFromTitle(heading[1]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!currentKey || !line.startsWith('| ')) continue;
|
|
150
|
+
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
|
151
|
+
if (cells.length === 0 || cells[0] === 'Stack') continue;
|
|
152
|
+
const name = cells[0].replace(/^`|`$/g, '');
|
|
153
|
+
result[currentKey].push(name);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|