@thejoseki/clawform 2.3.0 → 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 +34 -2
- 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 +15 -2
- 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/commands/deploy.js
CHANGED
|
@@ -1,413 +1,413 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import inquirer from 'inquirer';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { readEnv } from '../lib/rename-compat.js';
|
|
5
|
-
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
|
6
|
-
import { logger } from '../lib/logger.js';
|
|
7
|
-
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
-
import { loadConfig, resolveStackTags } from '../lib/config.js';
|
|
9
|
-
import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
|
|
10
|
-
import { addPendingChangeset, clearPendingChangeset } from '../lib/session-state.js';
|
|
11
|
-
import { ENVS, isDataResource } from '../lib/constants.js';
|
|
12
|
-
import { loadCfnTemplate, extractExportingOutputKeys, extractLiteralExportNames } from '../lib/cfn-yaml.js';
|
|
13
|
-
import {
|
|
14
|
-
findRemovedExports,
|
|
15
|
-
checkExportLock,
|
|
16
|
-
assertNoFrozenExports,
|
|
17
|
-
assertNoFrozenTemplateExports,
|
|
18
|
-
} from '../aws/exports.js';
|
|
19
|
-
import {
|
|
20
|
-
stackStatus,
|
|
21
|
-
decideChangeSetType,
|
|
22
|
-
createAndWait,
|
|
23
|
-
classifyChanges,
|
|
24
|
-
executeAndPoll,
|
|
25
|
-
} from '../aws/changeset.js';
|
|
26
|
-
|
|
27
|
-
// Runtime account guard: refuse to deploy when the caller's AWS account does
|
|
28
|
-
// not match the consumer's clawform.config.json `accounts[env]` mapping.
|
|
29
|
-
//
|
|
30
|
-
// Why a separate exported helper:
|
|
31
|
-
// - Lets tests mock `@aws-sdk/client-sts` and drive the 4 branches
|
|
32
|
-
// (match, mismatch, no-guard-configured, break-glass) without standing up
|
|
33
|
-
// the rest of deploy().
|
|
34
|
-
// - Keeps the deploy() body linear — one call, one place to land the guard.
|
|
35
|
-
//
|
|
36
|
-
// Args:
|
|
37
|
-
// env — env string ('d' | 'stg' | 'p').
|
|
38
|
-
// accounts — projectCfg.accounts (already loaded by deploy()). May be
|
|
39
|
-
// missing, empty, or {env: ''} — all "no guard" cases.
|
|
40
|
-
// region — AWS region for the STS client (matches the CFN region).
|
|
41
|
-
// stsClientFactory — optional override; defaults to a fresh STSClient. Tests
|
|
42
|
-
// use vi.mock('@aws-sdk/client-sts') instead and don't need
|
|
43
|
-
// this. Kept for callers who want to inject a stub.
|
|
44
|
-
//
|
|
45
|
-
// Resolution order:
|
|
46
|
-
// 1. CLAWFORM_SKIP_ACCOUNT_CHECK=1 → bypass with yellow warning (break-glass).
|
|
47
|
-
// 2. accounts[env] empty / undefined → skip with yellow warning.
|
|
48
|
-
// 3. STS get-caller-identity → compare → throw on mismatch.
|
|
49
|
-
//
|
|
50
|
-
// Throws on mismatch. Resolves with how it concluded — 'verified' (STS ran and
|
|
51
|
-
// matched), 'skipped' (no account configured, non-prod), or 'bypassed'
|
|
52
|
-
// (break-glass env var) — so callers can make the unattended-prod decision.
|
|
53
|
-
export async function assertAccountMatches({ env, accounts, region, stsClientFactory } = {}) {
|
|
54
|
-
if (readEnv('SKIP_ACCOUNT_CHECK') === '1') {
|
|
55
|
-
logger.warn(
|
|
56
|
-
`CLAWFORM_SKIP_ACCOUNT_CHECK=1 — bypassing account-match guard for env '${env}'` +
|
|
57
|
-
`${env === 'p' ? ' (PRODUCTION)' : ''}. Break-glass only; unset to re-enable.`,
|
|
58
|
-
);
|
|
59
|
-
return 'bypassed';
|
|
60
|
-
}
|
|
61
|
-
const expectedRaw = accounts && typeof accounts === 'object' ? accounts[env] : undefined;
|
|
62
|
-
const expected = typeof expectedRaw === 'string' ? expectedRaw.trim() : '';
|
|
63
|
-
if (!expected) {
|
|
64
|
-
// Production is the one env where an unset guard is refused rather than
|
|
65
|
-
// warned past — a p deploy into the wrong account is the exact accident
|
|
66
|
-
// this check exists for. d/stg stay lenient: an account not yet pinned is
|
|
67
|
-
// a legitimate bootstrap state.
|
|
68
|
-
if (env === 'p') {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`Env 'p' requires accounts.p in clawform.config.json — a production deploy without ` +
|
|
71
|
-
`an account guard is refused. Add the 12-digit account id (edit the file or rerun clawform init --force).`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
logger.warn(`No account guard configured for env '${env}' — skipping account-match check.`);
|
|
75
|
-
return 'skipped';
|
|
76
|
-
}
|
|
77
|
-
const client = stsClientFactory ? stsClientFactory() : new STSClient(region ? { region } : {});
|
|
78
|
-
const resp = await client.send(new GetCallerIdentityCommand({}));
|
|
79
|
-
const caller = resp && resp.Account ? String(resp.Account).trim() : '';
|
|
80
|
-
if (caller !== expected) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`Account mismatch: caller is ${caller || '(unknown)'} but env '${env}' is configured for ${expected} ` +
|
|
83
|
-
`in clawform.config.json. Refusing to deploy.`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
return 'verified';
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Unattended production deploys are fine for CI — CodePipeline's manual
|
|
90
|
-
// approval already puts a human in the loop — but only when the account guard
|
|
91
|
-
// genuinely ran and matched. autoApprove combined with a bypassed guard on p
|
|
92
|
-
// would mean nothing checked which account is being written to.
|
|
93
|
-
export function assertProdDeployAllowed({ env, autoApprove, accountCheckMode } = {}) {
|
|
94
|
-
if (env !== 'p' || !autoApprove) return;
|
|
95
|
-
if (accountCheckMode !== 'verified') {
|
|
96
|
-
throw new Error(
|
|
97
|
-
`--auto-approve is refused for a production deploy unless the account guard verified the ` +
|
|
98
|
-
`caller (guard was '${accountCheckMode}'). Configure accounts.p and unset CLAWFORM_SKIP_ACCOUNT_CHECK, ` +
|
|
99
|
-
`or run interactively.`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// C1 (export-lock pre-check): an UPDATE that removes (or un-exports) a live
|
|
105
|
-
// export still imported elsewhere is guaranteed to fail at execute time with
|
|
106
|
-
// "Export ... in use by" and roll back. Detect it BEFORE creating the changeset
|
|
107
|
-
// (all inputs — live Outputs and the template body — exist by then) and hand
|
|
108
|
-
// the operator the two-step migration instead of a wedged stack (rules/limits.md).
|
|
109
|
-
//
|
|
110
|
-
// Fail-open on our own inability to check (parse failure, ListImports denied
|
|
111
|
-
// with no importer seen) — CloudFormation still refuses the real removal if
|
|
112
|
-
// it's genuinely in use. Fail-closed on any confirmed importer, including
|
|
113
|
-
// partial pagination proof (see checkExportLock).
|
|
114
|
-
export async function assertExportLockClear({ client, liveOutputs, templateBody } = {}) {
|
|
115
|
-
const exportsLive = (liveOutputs ?? []).filter((o) => o && o.ExportName);
|
|
116
|
-
if (exportsLive.length === 0) return; // nothing exported → no lock possible
|
|
117
|
-
const doc = loadCfnTemplate(templateBody);
|
|
118
|
-
if (!doc) return; // unparseable → linters own the error
|
|
119
|
-
const removed = findRemovedExports(
|
|
120
|
-
liveOutputs,
|
|
121
|
-
extractExportingOutputKeys(doc),
|
|
122
|
-
extractLiteralExportNames(doc), // same export name under a new OutputKey = moved, not removed
|
|
123
|
-
);
|
|
124
|
-
if (removed.length === 0) return;
|
|
125
|
-
const { blocked, unchecked } = await checkExportLock(client, removed);
|
|
126
|
-
for (const u of unchecked) {
|
|
127
|
-
logger.warn(
|
|
128
|
-
`Could not verify importers for export "${u.exportName}" (${u.error}) — proceeding; ` +
|
|
129
|
-
`CloudFormation will still refuse the removal if it's in use.`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
if (blocked.length === 0) return;
|
|
133
|
-
const lines = blocked.map((b) => ` • ${b.exportName} — imported by: ${b.importers.join(', ')}`);
|
|
134
|
-
throw new Error(
|
|
135
|
-
`Export-lock: this update removes ${blocked.length} export(s) still imported by other stack(s):\n` +
|
|
136
|
-
`${lines.join('\n')}\n` +
|
|
137
|
-
`CloudFormation will reject the changeset ("Export ... in use by") and roll back. ` +
|
|
138
|
-
`Use the two-step migration (rules/limits.md):\n` +
|
|
139
|
-
` 1. Keep the old export; add the new export alongside it and deploy this producer first.\n` +
|
|
140
|
-
` 2. Migrate each consumer to the new import, deploy them in dependency order, then retire the\n` +
|
|
141
|
-
` old export in a final producer deploy once "aws cloudformation list-imports" is empty.`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export default async function deploy({ file, env, stackName, project, params, capabilities, autoApprove }) {
|
|
146
|
-
if (!ENVS.includes(env)) {
|
|
147
|
-
throw new Error(`Invalid env "${env}". Expected one of: ${ENVS.join(' | ')}`);
|
|
148
|
-
}
|
|
149
|
-
if (!stackName) throw new Error('--stack-name is required.');
|
|
150
|
-
if (!project) throw new Error('--project is required (used as Project tag value).');
|
|
151
|
-
|
|
152
|
-
const projectDir = resolveProjectDir();
|
|
153
|
-
const projectCfg = loadProjectConfig({ projectDir });
|
|
154
|
-
const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
155
|
-
if (legacyRe && legacyRe.test(stackName)) {
|
|
156
|
-
const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
|
|
157
|
-
throw new Error(
|
|
158
|
-
`Stack "${stackName}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
|
|
159
|
-
`clawform deploy refuses to modify legacy stacks. See rules/legacy-freeze.md and docs/config-schema.md.\n` +
|
|
160
|
-
`Use raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment if modification is unavoidable.`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// loadConfig() still gates AWS_PROFILE / AWS_REGION with the friendly error.
|
|
165
|
-
// OWNER / COST_CENTER are no longer required here — resolveStackTags() owns
|
|
166
|
-
// the env-var-then-tags_defaults chain and throws with a usable message
|
|
167
|
-
// when both sources are empty.
|
|
168
|
-
const cfg = loadConfig({});
|
|
169
|
-
const stackTags = resolveStackTags(projectCfg, env);
|
|
170
|
-
|
|
171
|
-
// Runtime account guard. Runs after loadConfig() so the friendly missing-creds
|
|
172
|
-
// error fires first; runs before any CFN call so the engineer doesn't have to
|
|
173
|
-
// wait on a changeset round-trip to discover they're pointed at the wrong AWS
|
|
174
|
-
// account. See docs/config-schema.md "Wired today" for resolution rules.
|
|
175
|
-
const accountCheckMode = await assertAccountMatches({
|
|
176
|
-
env,
|
|
177
|
-
accounts: projectCfg.accounts,
|
|
178
|
-
region: cfg.AWS_REGION,
|
|
179
|
-
});
|
|
180
|
-
assertProdDeployAllowed({ env, autoApprove, accountCheckMode });
|
|
181
|
-
|
|
182
|
-
const templateBody = await readFile(file, 'utf8');
|
|
183
|
-
|
|
184
|
-
// C2 introduction gate: refuse a template whose literal Export.Name strings
|
|
185
|
-
// would CREATE a legacy-prefixed export (covers CREATE deploys too — the
|
|
186
|
-
// live-outputs gate below only sees exports that already exist). Anchored
|
|
187
|
-
// regex: the prefix must lead the export name, so legacy tokens mid-name in
|
|
188
|
-
// convention-compliant exports don't false-positive.
|
|
189
|
-
const anchoredLegacyRe = buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
190
|
-
assertNoFrozenTemplateExports({
|
|
191
|
-
templateExportNames: extractLiteralExportNames(loadCfnTemplate(templateBody)),
|
|
192
|
-
legacyRe: anchoredLegacyRe,
|
|
193
|
-
stackName,
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
let parameters = [];
|
|
197
|
-
if (params) {
|
|
198
|
-
const raw = await readFile(params, 'utf8');
|
|
199
|
-
parameters = JSON.parse(raw);
|
|
200
|
-
if (!Array.isArray(parameters)) {
|
|
201
|
-
throw new Error(`${params}: expected JSON array of {ParameterKey, ParameterValue}.`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Tag key spelling pinned by rules/tagging.md — Env (not Environment), and
|
|
206
|
-
// the 5 mandatory keys (Project, Env, Owner, CostCenter, ManagedBy). Customer
|
|
207
|
-
// from resolveStackTags is informational and not emitted as a tag here.
|
|
208
|
-
const tags = [
|
|
209
|
-
{ Key: 'Project', Value: project },
|
|
210
|
-
{ Key: 'Env', Value: stackTags.Environment },
|
|
211
|
-
{ Key: 'Owner', Value: stackTags.Owner },
|
|
212
|
-
{ Key: 'CostCenter', Value: stackTags.CostCenter },
|
|
213
|
-
{ Key: 'ManagedBy', Value: stackTags.ManagedBy },
|
|
214
|
-
];
|
|
215
|
-
|
|
216
|
-
const caps = capabilities ? capabilities.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
217
|
-
|
|
218
|
-
const client = cfnClient();
|
|
219
|
-
const status = await stackStatus(client, stackName);
|
|
220
|
-
|
|
221
|
-
if (status.exists) {
|
|
222
|
-
// C2: frozen-export gate — refuse a stack emitting legacy-prefixed exports
|
|
223
|
-
// even when its name doesn't match. Anchored regex (leading segment only).
|
|
224
|
-
assertNoFrozenExports({ liveOutputs: status.stack?.Outputs, legacyRe: anchoredLegacyRe, stackName });
|
|
225
|
-
|
|
226
|
-
// C1: export-lock pre-check. Runs BEFORE the changeset — both inputs (live
|
|
227
|
-
// Outputs, template body) already exist, and a blocked deploy should not
|
|
228
|
-
// pay a CreateChangeSet round-trip or leave an orphaned changeset behind.
|
|
229
|
-
await assertExportLockClear({ client, liveOutputs: status.stack?.Outputs, templateBody });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const changeSetType = decideChangeSetType(status);
|
|
233
|
-
logger.info(`Stack ${stackName}: ${status.exists ? status.status : '(does not exist)'} → ${changeSetType}`);
|
|
234
|
-
|
|
235
|
-
logger.step(`Creating change-set on ${stackName}…`);
|
|
236
|
-
const cs = await createAndWait(client, {
|
|
237
|
-
stackName,
|
|
238
|
-
changeSetType,
|
|
239
|
-
templateBody,
|
|
240
|
-
parameters,
|
|
241
|
-
tags,
|
|
242
|
-
capabilities: caps,
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
if (cs.isEmpty) {
|
|
246
|
-
logger.ok(`No changes (${cs.statusReason}). Empty change-set deleted.`);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// In-flight state: the changeset now exists in AWS but hasn't executed. Record
|
|
251
|
-
// it so that if the operator aborts at the confirm prompt (or runs /clear),
|
|
252
|
-
// the next session's context hook warns a changeset is sitting unexecuted.
|
|
253
|
-
// Cleared after execute below. Fail-open — a state-write problem must never
|
|
254
|
-
// block the deploy.
|
|
255
|
-
try {
|
|
256
|
-
addPendingChangeset(projectDir, {
|
|
257
|
-
stack: stackName,
|
|
258
|
-
changeSetName: cs.name,
|
|
259
|
-
env,
|
|
260
|
-
createdAt: new Date().toISOString(),
|
|
261
|
-
});
|
|
262
|
-
} catch (err) {
|
|
263
|
-
logger.warn(`Could not record pending changeset state (continuing): ${err.message}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const buckets = classifyChanges(cs.changes);
|
|
267
|
-
renderChangeTable(buckets);
|
|
268
|
-
|
|
269
|
-
const dataRemove = buckets.remove.filter((r) => isDataResource(r.resourceType));
|
|
270
|
-
const needsRetype = buckets.dataResourceReplace.length > 0 || dataRemove.length > 0;
|
|
271
|
-
|
|
272
|
-
if (autoApprove) {
|
|
273
|
-
if (buckets.replace.length > 0 || needsRetype) {
|
|
274
|
-
throw new Error('--auto-approve refused: change-set includes Replace or data-resource removal.');
|
|
275
|
-
}
|
|
276
|
-
logger.info('Proceeding (--auto-approve, no Replace / data removal).');
|
|
277
|
-
} else {
|
|
278
|
-
// Production always earns the retype gate, whatever the changeset holds —
|
|
279
|
-
// the extra keystroke is the point. Folded into the same prompt as the
|
|
280
|
-
// data-resource retype so an operator never types the name twice.
|
|
281
|
-
const prodRetype = env === 'p';
|
|
282
|
-
if (prodRetype && !needsRetype) {
|
|
283
|
-
logger.warn(`Production deploy to '${stackName}' — confirm by retyping the stack name.`);
|
|
284
|
-
}
|
|
285
|
-
if (needsRetype || prodRetype) {
|
|
286
|
-
if (buckets.dataResourceReplace.length > 0) {
|
|
287
|
-
logger.warn(`${buckets.dataResourceReplace.length} data resource(s) will be REPLACED — physical resource recreated.`);
|
|
288
|
-
// A4 (plain language): spell out the consequence so a non-DevOps operator
|
|
289
|
-
// understands what "Replace" costs before they retype the stack name.
|
|
290
|
-
logger.warn(' In plain terms: a brand-new resource is created and the old one is destroyed. Live data');
|
|
291
|
-
logger.warn(' on the old resource is NOT migrated — it survives only as a snapshot if DeletionPolicy/');
|
|
292
|
-
logger.warn(' UpdateReplacePolicy is Snapshot (RDS/Aurora) or Retain. Verify that before continuing.');
|
|
293
|
-
}
|
|
294
|
-
if (dataRemove.length > 0) {
|
|
295
|
-
logger.warn(`${dataRemove.length} data resource(s) will be REMOVED from the stack — physical fate depends on DeletionPolicy (change-set does not expose it).`);
|
|
296
|
-
for (const r of dataRemove) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
297
|
-
logger.warn(' In plain terms: CloudFormation stops managing these. If DeletionPolicy is Retain/Snapshot the');
|
|
298
|
-
logger.warn(' underlying data survives; otherwise it is deleted. Confirm the policy before continuing.');
|
|
299
|
-
}
|
|
300
|
-
const { typed } = await inquirer.prompt([{
|
|
301
|
-
type: 'input',
|
|
302
|
-
name: 'typed',
|
|
303
|
-
message: `Type the stack name EXACTLY to confirm:`,
|
|
304
|
-
validate: (v) => v.trim() === stackName ? true : `Type "${stackName}" exactly.`,
|
|
305
|
-
}]);
|
|
306
|
-
if (typed.trim() !== stackName) throw new Error('Aborted: stack name did not match.');
|
|
307
|
-
}
|
|
308
|
-
const { ok } = await inquirer.prompt([{
|
|
309
|
-
type: 'confirm',
|
|
310
|
-
name: 'ok',
|
|
311
|
-
message: `Execute change-set ${cs.name} on ${stackName}?`,
|
|
312
|
-
default: false,
|
|
313
|
-
}]);
|
|
314
|
-
if (!ok) {
|
|
315
|
-
logger.warn('Aborted by user. Change-set kept (delete via AWS console or rerun).');
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
logger.step(`Executing change-set ${cs.name}…`);
|
|
321
|
-
const result = await executeAndPoll(client, {
|
|
322
|
-
stackName,
|
|
323
|
-
changeSetName: cs.name,
|
|
324
|
-
onEvent: (ev) => {
|
|
325
|
-
const color = EVENT_COLOR[ev.ResourceStatus] ?? ((s) => s);
|
|
326
|
-
const reason = ev.ResourceStatusReason ? ` ${chalk.gray('—')} ${ev.ResourceStatusReason}` : '';
|
|
327
|
-
logger.info(` ${(ev.LogicalResourceId ?? '').padEnd(30)} ${color(ev.ResourceStatus)}${reason}`);
|
|
328
|
-
},
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
// The changeset has been executed (success or failure) — no longer pending.
|
|
332
|
-
try {
|
|
333
|
-
clearPendingChangeset(projectDir, { stack: stackName, changeSetName: cs.name });
|
|
334
|
-
} catch { /* fail-open — never let a state-write problem mask the deploy result */ }
|
|
335
|
-
|
|
336
|
-
if (/COMPLETE$/.test(result.stackStatus) && !/ROLLBACK/.test(result.stackStatus)) {
|
|
337
|
-
logger.ok(`Stack ${stackName} → ${result.stackStatus}`);
|
|
338
|
-
const outs = result.stack?.Outputs ?? [];
|
|
339
|
-
if (outs.length) {
|
|
340
|
-
logger.info('Outputs:');
|
|
341
|
-
for (const o of outs) {
|
|
342
|
-
logger.info(` ${(o.OutputKey ?? '').padEnd(30)} = ${o.OutputValue ?? ''}`);
|
|
343
|
-
}
|
|
344
|
-
highlightEndpoints(outs);
|
|
345
|
-
}
|
|
346
|
-
} else {
|
|
347
|
-
logger.error(`Stack ${stackName} → ${result.stackStatus}. Use /rollback to investigate.`);
|
|
348
|
-
process.exit(1);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// D2 (post-deploy verification): a clean COMPLETE means CloudFormation finished,
|
|
353
|
-
// not that the app works. Surface endpoint-like outputs so the operator has
|
|
354
|
-
// something concrete to hit before calling the deploy done. Heuristic on the
|
|
355
|
-
// OutputKey name — intentionally loose; a false highlight costs nothing.
|
|
356
|
-
// Keep in sync with the canonical export suffixes in rules/cfn-standards.md
|
|
357
|
-
// (-id, -arn, -name, -dns, -uri) — 'uri' in particular is the convention's own
|
|
358
|
-
// endpoint suffix.
|
|
359
|
-
const ENDPOINT_KEY_RE = /(url|uri|endpoint|dns|domain|address|hostname|fqdn)/i;
|
|
360
|
-
|
|
361
|
-
export function highlightEndpoints(outs) {
|
|
362
|
-
const endpoints = (outs ?? []).filter((o) => ENDPOINT_KEY_RE.test(o.OutputKey ?? '') && o.OutputValue);
|
|
363
|
-
if (endpoints.length === 0) return;
|
|
364
|
-
logger.info('');
|
|
365
|
-
logger.info('Endpoints to verify:');
|
|
366
|
-
for (const o of endpoints) logger.info(` ${chalk.cyan(o.OutputValue)} ${chalk.gray(`(${o.OutputKey})`)}`);
|
|
367
|
-
logger.info(chalk.gray('A clean stack status means CloudFormation finished — confirm each endpoint actually responds before considering the deploy done.'));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const EVENT_COLOR = {
|
|
371
|
-
CREATE_COMPLETE: chalk.green,
|
|
372
|
-
UPDATE_COMPLETE: chalk.green,
|
|
373
|
-
CREATE_IN_PROGRESS: chalk.cyan,
|
|
374
|
-
UPDATE_IN_PROGRESS: chalk.cyan,
|
|
375
|
-
DELETE_COMPLETE: chalk.gray,
|
|
376
|
-
DELETE_IN_PROGRESS: chalk.gray,
|
|
377
|
-
CREATE_FAILED: chalk.red,
|
|
378
|
-
UPDATE_FAILED: chalk.red,
|
|
379
|
-
DELETE_FAILED: chalk.red,
|
|
380
|
-
ROLLBACK_IN_PROGRESS: chalk.yellow,
|
|
381
|
-
ROLLBACK_COMPLETE: chalk.yellow,
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
function renderChangeTable(buckets) {
|
|
385
|
-
logger.info('');
|
|
386
|
-
logger.info('Change-set summary:');
|
|
387
|
-
logger.info(` ${chalk.green('+ Add')} ${buckets.add.length}`);
|
|
388
|
-
logger.info(` ${chalk.cyan('~ Modify')} ${buckets.modify.length}`);
|
|
389
|
-
logger.info(` ${chalk.yellow('! Replace')} ${buckets.replace.length}`);
|
|
390
|
-
logger.info(` ${chalk.red('- Remove')} ${buckets.remove.length}`);
|
|
391
|
-
|
|
392
|
-
if (buckets.dataResourceReplace.length > 0) {
|
|
393
|
-
logger.info('');
|
|
394
|
-
logger.warn('Data-resource Replace (DESTRUCTIVE):');
|
|
395
|
-
for (const r of buckets.dataResourceReplace) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
396
|
-
}
|
|
397
|
-
printBucket('Add', buckets.add, chalk.green, '+');
|
|
398
|
-
printBucket('Modify', buckets.modify, chalk.cyan, '~', true);
|
|
399
|
-
printBucket('Replace', buckets.replace, chalk.yellow, '!');
|
|
400
|
-
printBucket('Remove', buckets.remove, chalk.red, '-');
|
|
401
|
-
logger.info('');
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function printBucket(label, items, color, marker, showProps = false) {
|
|
405
|
-
if (items.length === 0) return;
|
|
406
|
-
logger.info('');
|
|
407
|
-
logger.info(`${label} (${items.length}):`);
|
|
408
|
-
for (const r of items) {
|
|
409
|
-
const props = showProps && r.detailsTargets.length ? ` props=[${r.detailsTargets.join(', ')}]` : '';
|
|
410
|
-
const repl = r.replacement && r.replacement !== 'False' ? ` replacement=${r.replacement}` : '';
|
|
411
|
-
logger.info(` ${color(marker)} ${r.logicalId} (${r.resourceType})${repl}${props}`);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { readEnv } from '../lib/rename-compat.js';
|
|
5
|
+
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
+
import { loadConfig, resolveStackTags } from '../lib/config.js';
|
|
9
|
+
import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
|
|
10
|
+
import { addPendingChangeset, clearPendingChangeset } from '../lib/session-state.js';
|
|
11
|
+
import { ENVS, isDataResource } from '../lib/constants.js';
|
|
12
|
+
import { loadCfnTemplate, extractExportingOutputKeys, extractLiteralExportNames } from '../lib/cfn-yaml.js';
|
|
13
|
+
import {
|
|
14
|
+
findRemovedExports,
|
|
15
|
+
checkExportLock,
|
|
16
|
+
assertNoFrozenExports,
|
|
17
|
+
assertNoFrozenTemplateExports,
|
|
18
|
+
} from '../aws/exports.js';
|
|
19
|
+
import {
|
|
20
|
+
stackStatus,
|
|
21
|
+
decideChangeSetType,
|
|
22
|
+
createAndWait,
|
|
23
|
+
classifyChanges,
|
|
24
|
+
executeAndPoll,
|
|
25
|
+
} from '../aws/changeset.js';
|
|
26
|
+
|
|
27
|
+
// Runtime account guard: refuse to deploy when the caller's AWS account does
|
|
28
|
+
// not match the consumer's clawform.config.json `accounts[env]` mapping.
|
|
29
|
+
//
|
|
30
|
+
// Why a separate exported helper:
|
|
31
|
+
// - Lets tests mock `@aws-sdk/client-sts` and drive the 4 branches
|
|
32
|
+
// (match, mismatch, no-guard-configured, break-glass) without standing up
|
|
33
|
+
// the rest of deploy().
|
|
34
|
+
// - Keeps the deploy() body linear — one call, one place to land the guard.
|
|
35
|
+
//
|
|
36
|
+
// Args:
|
|
37
|
+
// env — env string ('d' | 'stg' | 'p').
|
|
38
|
+
// accounts — projectCfg.accounts (already loaded by deploy()). May be
|
|
39
|
+
// missing, empty, or {env: ''} — all "no guard" cases.
|
|
40
|
+
// region — AWS region for the STS client (matches the CFN region).
|
|
41
|
+
// stsClientFactory — optional override; defaults to a fresh STSClient. Tests
|
|
42
|
+
// use vi.mock('@aws-sdk/client-sts') instead and don't need
|
|
43
|
+
// this. Kept for callers who want to inject a stub.
|
|
44
|
+
//
|
|
45
|
+
// Resolution order:
|
|
46
|
+
// 1. CLAWFORM_SKIP_ACCOUNT_CHECK=1 → bypass with yellow warning (break-glass).
|
|
47
|
+
// 2. accounts[env] empty / undefined → skip with yellow warning.
|
|
48
|
+
// 3. STS get-caller-identity → compare → throw on mismatch.
|
|
49
|
+
//
|
|
50
|
+
// Throws on mismatch. Resolves with how it concluded — 'verified' (STS ran and
|
|
51
|
+
// matched), 'skipped' (no account configured, non-prod), or 'bypassed'
|
|
52
|
+
// (break-glass env var) — so callers can make the unattended-prod decision.
|
|
53
|
+
export async function assertAccountMatches({ env, accounts, region, stsClientFactory } = {}) {
|
|
54
|
+
if (readEnv('SKIP_ACCOUNT_CHECK') === '1') {
|
|
55
|
+
logger.warn(
|
|
56
|
+
`CLAWFORM_SKIP_ACCOUNT_CHECK=1 — bypassing account-match guard for env '${env}'` +
|
|
57
|
+
`${env === 'p' ? ' (PRODUCTION)' : ''}. Break-glass only; unset to re-enable.`,
|
|
58
|
+
);
|
|
59
|
+
return 'bypassed';
|
|
60
|
+
}
|
|
61
|
+
const expectedRaw = accounts && typeof accounts === 'object' ? accounts[env] : undefined;
|
|
62
|
+
const expected = typeof expectedRaw === 'string' ? expectedRaw.trim() : '';
|
|
63
|
+
if (!expected) {
|
|
64
|
+
// Production is the one env where an unset guard is refused rather than
|
|
65
|
+
// warned past — a p deploy into the wrong account is the exact accident
|
|
66
|
+
// this check exists for. d/stg stay lenient: an account not yet pinned is
|
|
67
|
+
// a legitimate bootstrap state.
|
|
68
|
+
if (env === 'p') {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Env 'p' requires accounts.p in clawform.config.json — a production deploy without ` +
|
|
71
|
+
`an account guard is refused. Add the 12-digit account id (edit the file or rerun clawform init --force).`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
logger.warn(`No account guard configured for env '${env}' — skipping account-match check.`);
|
|
75
|
+
return 'skipped';
|
|
76
|
+
}
|
|
77
|
+
const client = stsClientFactory ? stsClientFactory() : new STSClient(region ? { region } : {});
|
|
78
|
+
const resp = await client.send(new GetCallerIdentityCommand({}));
|
|
79
|
+
const caller = resp && resp.Account ? String(resp.Account).trim() : '';
|
|
80
|
+
if (caller !== expected) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Account mismatch: caller is ${caller || '(unknown)'} but env '${env}' is configured for ${expected} ` +
|
|
83
|
+
`in clawform.config.json. Refusing to deploy.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return 'verified';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unattended production deploys are fine for CI — CodePipeline's manual
|
|
90
|
+
// approval already puts a human in the loop — but only when the account guard
|
|
91
|
+
// genuinely ran and matched. autoApprove combined with a bypassed guard on p
|
|
92
|
+
// would mean nothing checked which account is being written to.
|
|
93
|
+
export function assertProdDeployAllowed({ env, autoApprove, accountCheckMode } = {}) {
|
|
94
|
+
if (env !== 'p' || !autoApprove) return;
|
|
95
|
+
if (accountCheckMode !== 'verified') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`--auto-approve is refused for a production deploy unless the account guard verified the ` +
|
|
98
|
+
`caller (guard was '${accountCheckMode}'). Configure accounts.p and unset CLAWFORM_SKIP_ACCOUNT_CHECK, ` +
|
|
99
|
+
`or run interactively.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// C1 (export-lock pre-check): an UPDATE that removes (or un-exports) a live
|
|
105
|
+
// export still imported elsewhere is guaranteed to fail at execute time with
|
|
106
|
+
// "Export ... in use by" and roll back. Detect it BEFORE creating the changeset
|
|
107
|
+
// (all inputs — live Outputs and the template body — exist by then) and hand
|
|
108
|
+
// the operator the two-step migration instead of a wedged stack (rules/limits.md).
|
|
109
|
+
//
|
|
110
|
+
// Fail-open on our own inability to check (parse failure, ListImports denied
|
|
111
|
+
// with no importer seen) — CloudFormation still refuses the real removal if
|
|
112
|
+
// it's genuinely in use. Fail-closed on any confirmed importer, including
|
|
113
|
+
// partial pagination proof (see checkExportLock).
|
|
114
|
+
export async function assertExportLockClear({ client, liveOutputs, templateBody } = {}) {
|
|
115
|
+
const exportsLive = (liveOutputs ?? []).filter((o) => o && o.ExportName);
|
|
116
|
+
if (exportsLive.length === 0) return; // nothing exported → no lock possible
|
|
117
|
+
const doc = loadCfnTemplate(templateBody);
|
|
118
|
+
if (!doc) return; // unparseable → linters own the error
|
|
119
|
+
const removed = findRemovedExports(
|
|
120
|
+
liveOutputs,
|
|
121
|
+
extractExportingOutputKeys(doc),
|
|
122
|
+
extractLiteralExportNames(doc), // same export name under a new OutputKey = moved, not removed
|
|
123
|
+
);
|
|
124
|
+
if (removed.length === 0) return;
|
|
125
|
+
const { blocked, unchecked } = await checkExportLock(client, removed);
|
|
126
|
+
for (const u of unchecked) {
|
|
127
|
+
logger.warn(
|
|
128
|
+
`Could not verify importers for export "${u.exportName}" (${u.error}) — proceeding; ` +
|
|
129
|
+
`CloudFormation will still refuse the removal if it's in use.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (blocked.length === 0) return;
|
|
133
|
+
const lines = blocked.map((b) => ` • ${b.exportName} — imported by: ${b.importers.join(', ')}`);
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Export-lock: this update removes ${blocked.length} export(s) still imported by other stack(s):\n` +
|
|
136
|
+
`${lines.join('\n')}\n` +
|
|
137
|
+
`CloudFormation will reject the changeset ("Export ... in use by") and roll back. ` +
|
|
138
|
+
`Use the two-step migration (rules/limits.md):\n` +
|
|
139
|
+
` 1. Keep the old export; add the new export alongside it and deploy this producer first.\n` +
|
|
140
|
+
` 2. Migrate each consumer to the new import, deploy them in dependency order, then retire the\n` +
|
|
141
|
+
` old export in a final producer deploy once "aws cloudformation list-imports" is empty.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default async function deploy({ file, env, stackName, project, params, capabilities, autoApprove }) {
|
|
146
|
+
if (!ENVS.includes(env)) {
|
|
147
|
+
throw new Error(`Invalid env "${env}". Expected one of: ${ENVS.join(' | ')}`);
|
|
148
|
+
}
|
|
149
|
+
if (!stackName) throw new Error('--stack-name is required.');
|
|
150
|
+
if (!project) throw new Error('--project is required (used as Project tag value).');
|
|
151
|
+
|
|
152
|
+
const projectDir = resolveProjectDir();
|
|
153
|
+
const projectCfg = loadProjectConfig({ projectDir });
|
|
154
|
+
const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
155
|
+
if (legacyRe && legacyRe.test(stackName)) {
|
|
156
|
+
const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Stack "${stackName}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
|
|
159
|
+
`clawform deploy refuses to modify legacy stacks. See rules/legacy-freeze.md and docs/config-schema.md.\n` +
|
|
160
|
+
`Use raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment if modification is unavoidable.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// loadConfig() still gates AWS_PROFILE / AWS_REGION with the friendly error.
|
|
165
|
+
// OWNER / COST_CENTER are no longer required here — resolveStackTags() owns
|
|
166
|
+
// the env-var-then-tags_defaults chain and throws with a usable message
|
|
167
|
+
// when both sources are empty.
|
|
168
|
+
const cfg = loadConfig({});
|
|
169
|
+
const stackTags = resolveStackTags(projectCfg, env);
|
|
170
|
+
|
|
171
|
+
// Runtime account guard. Runs after loadConfig() so the friendly missing-creds
|
|
172
|
+
// error fires first; runs before any CFN call so the engineer doesn't have to
|
|
173
|
+
// wait on a changeset round-trip to discover they're pointed at the wrong AWS
|
|
174
|
+
// account. See docs/config-schema.md "Wired today" for resolution rules.
|
|
175
|
+
const accountCheckMode = await assertAccountMatches({
|
|
176
|
+
env,
|
|
177
|
+
accounts: projectCfg.accounts,
|
|
178
|
+
region: cfg.AWS_REGION,
|
|
179
|
+
});
|
|
180
|
+
assertProdDeployAllowed({ env, autoApprove, accountCheckMode });
|
|
181
|
+
|
|
182
|
+
const templateBody = await readFile(file, 'utf8');
|
|
183
|
+
|
|
184
|
+
// C2 introduction gate: refuse a template whose literal Export.Name strings
|
|
185
|
+
// would CREATE a legacy-prefixed export (covers CREATE deploys too — the
|
|
186
|
+
// live-outputs gate below only sees exports that already exist). Anchored
|
|
187
|
+
// regex: the prefix must lead the export name, so legacy tokens mid-name in
|
|
188
|
+
// convention-compliant exports don't false-positive.
|
|
189
|
+
const anchoredLegacyRe = buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
190
|
+
assertNoFrozenTemplateExports({
|
|
191
|
+
templateExportNames: extractLiteralExportNames(loadCfnTemplate(templateBody)),
|
|
192
|
+
legacyRe: anchoredLegacyRe,
|
|
193
|
+
stackName,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let parameters = [];
|
|
197
|
+
if (params) {
|
|
198
|
+
const raw = await readFile(params, 'utf8');
|
|
199
|
+
parameters = JSON.parse(raw);
|
|
200
|
+
if (!Array.isArray(parameters)) {
|
|
201
|
+
throw new Error(`${params}: expected JSON array of {ParameterKey, ParameterValue}.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Tag key spelling pinned by rules/tagging.md — Env (not Environment), and
|
|
206
|
+
// the 5 mandatory keys (Project, Env, Owner, CostCenter, ManagedBy). Customer
|
|
207
|
+
// from resolveStackTags is informational and not emitted as a tag here.
|
|
208
|
+
const tags = [
|
|
209
|
+
{ Key: 'Project', Value: project },
|
|
210
|
+
{ Key: 'Env', Value: stackTags.Environment },
|
|
211
|
+
{ Key: 'Owner', Value: stackTags.Owner },
|
|
212
|
+
{ Key: 'CostCenter', Value: stackTags.CostCenter },
|
|
213
|
+
{ Key: 'ManagedBy', Value: stackTags.ManagedBy },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const caps = capabilities ? capabilities.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
217
|
+
|
|
218
|
+
const client = cfnClient();
|
|
219
|
+
const status = await stackStatus(client, stackName);
|
|
220
|
+
|
|
221
|
+
if (status.exists) {
|
|
222
|
+
// C2: frozen-export gate — refuse a stack emitting legacy-prefixed exports
|
|
223
|
+
// even when its name doesn't match. Anchored regex (leading segment only).
|
|
224
|
+
assertNoFrozenExports({ liveOutputs: status.stack?.Outputs, legacyRe: anchoredLegacyRe, stackName });
|
|
225
|
+
|
|
226
|
+
// C1: export-lock pre-check. Runs BEFORE the changeset — both inputs (live
|
|
227
|
+
// Outputs, template body) already exist, and a blocked deploy should not
|
|
228
|
+
// pay a CreateChangeSet round-trip or leave an orphaned changeset behind.
|
|
229
|
+
await assertExportLockClear({ client, liveOutputs: status.stack?.Outputs, templateBody });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const changeSetType = decideChangeSetType(status);
|
|
233
|
+
logger.info(`Stack ${stackName}: ${status.exists ? status.status : '(does not exist)'} → ${changeSetType}`);
|
|
234
|
+
|
|
235
|
+
logger.step(`Creating change-set on ${stackName}…`);
|
|
236
|
+
const cs = await createAndWait(client, {
|
|
237
|
+
stackName,
|
|
238
|
+
changeSetType,
|
|
239
|
+
templateBody,
|
|
240
|
+
parameters,
|
|
241
|
+
tags,
|
|
242
|
+
capabilities: caps,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (cs.isEmpty) {
|
|
246
|
+
logger.ok(`No changes (${cs.statusReason}). Empty change-set deleted.`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// In-flight state: the changeset now exists in AWS but hasn't executed. Record
|
|
251
|
+
// it so that if the operator aborts at the confirm prompt (or runs /clear),
|
|
252
|
+
// the next session's context hook warns a changeset is sitting unexecuted.
|
|
253
|
+
// Cleared after execute below. Fail-open — a state-write problem must never
|
|
254
|
+
// block the deploy.
|
|
255
|
+
try {
|
|
256
|
+
addPendingChangeset(projectDir, {
|
|
257
|
+
stack: stackName,
|
|
258
|
+
changeSetName: cs.name,
|
|
259
|
+
env,
|
|
260
|
+
createdAt: new Date().toISOString(),
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logger.warn(`Could not record pending changeset state (continuing): ${err.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const buckets = classifyChanges(cs.changes);
|
|
267
|
+
renderChangeTable(buckets);
|
|
268
|
+
|
|
269
|
+
const dataRemove = buckets.remove.filter((r) => isDataResource(r.resourceType));
|
|
270
|
+
const needsRetype = buckets.dataResourceReplace.length > 0 || dataRemove.length > 0;
|
|
271
|
+
|
|
272
|
+
if (autoApprove) {
|
|
273
|
+
if (buckets.replace.length > 0 || needsRetype) {
|
|
274
|
+
throw new Error('--auto-approve refused: change-set includes Replace or data-resource removal.');
|
|
275
|
+
}
|
|
276
|
+
logger.info('Proceeding (--auto-approve, no Replace / data removal).');
|
|
277
|
+
} else {
|
|
278
|
+
// Production always earns the retype gate, whatever the changeset holds —
|
|
279
|
+
// the extra keystroke is the point. Folded into the same prompt as the
|
|
280
|
+
// data-resource retype so an operator never types the name twice.
|
|
281
|
+
const prodRetype = env === 'p';
|
|
282
|
+
if (prodRetype && !needsRetype) {
|
|
283
|
+
logger.warn(`Production deploy to '${stackName}' — confirm by retyping the stack name.`);
|
|
284
|
+
}
|
|
285
|
+
if (needsRetype || prodRetype) {
|
|
286
|
+
if (buckets.dataResourceReplace.length > 0) {
|
|
287
|
+
logger.warn(`${buckets.dataResourceReplace.length} data resource(s) will be REPLACED — physical resource recreated.`);
|
|
288
|
+
// A4 (plain language): spell out the consequence so a non-DevOps operator
|
|
289
|
+
// understands what "Replace" costs before they retype the stack name.
|
|
290
|
+
logger.warn(' In plain terms: a brand-new resource is created and the old one is destroyed. Live data');
|
|
291
|
+
logger.warn(' on the old resource is NOT migrated — it survives only as a snapshot if DeletionPolicy/');
|
|
292
|
+
logger.warn(' UpdateReplacePolicy is Snapshot (RDS/Aurora) or Retain. Verify that before continuing.');
|
|
293
|
+
}
|
|
294
|
+
if (dataRemove.length > 0) {
|
|
295
|
+
logger.warn(`${dataRemove.length} data resource(s) will be REMOVED from the stack — physical fate depends on DeletionPolicy (change-set does not expose it).`);
|
|
296
|
+
for (const r of dataRemove) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
297
|
+
logger.warn(' In plain terms: CloudFormation stops managing these. If DeletionPolicy is Retain/Snapshot the');
|
|
298
|
+
logger.warn(' underlying data survives; otherwise it is deleted. Confirm the policy before continuing.');
|
|
299
|
+
}
|
|
300
|
+
const { typed } = await inquirer.prompt([{
|
|
301
|
+
type: 'input',
|
|
302
|
+
name: 'typed',
|
|
303
|
+
message: `Type the stack name EXACTLY to confirm:`,
|
|
304
|
+
validate: (v) => v.trim() === stackName ? true : `Type "${stackName}" exactly.`,
|
|
305
|
+
}]);
|
|
306
|
+
if (typed.trim() !== stackName) throw new Error('Aborted: stack name did not match.');
|
|
307
|
+
}
|
|
308
|
+
const { ok } = await inquirer.prompt([{
|
|
309
|
+
type: 'confirm',
|
|
310
|
+
name: 'ok',
|
|
311
|
+
message: `Execute change-set ${cs.name} on ${stackName}?`,
|
|
312
|
+
default: false,
|
|
313
|
+
}]);
|
|
314
|
+
if (!ok) {
|
|
315
|
+
logger.warn('Aborted by user. Change-set kept (delete via AWS console or rerun).');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
logger.step(`Executing change-set ${cs.name}…`);
|
|
321
|
+
const result = await executeAndPoll(client, {
|
|
322
|
+
stackName,
|
|
323
|
+
changeSetName: cs.name,
|
|
324
|
+
onEvent: (ev) => {
|
|
325
|
+
const color = EVENT_COLOR[ev.ResourceStatus] ?? ((s) => s);
|
|
326
|
+
const reason = ev.ResourceStatusReason ? ` ${chalk.gray('—')} ${ev.ResourceStatusReason}` : '';
|
|
327
|
+
logger.info(` ${(ev.LogicalResourceId ?? '').padEnd(30)} ${color(ev.ResourceStatus)}${reason}`);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// The changeset has been executed (success or failure) — no longer pending.
|
|
332
|
+
try {
|
|
333
|
+
clearPendingChangeset(projectDir, { stack: stackName, changeSetName: cs.name });
|
|
334
|
+
} catch { /* fail-open — never let a state-write problem mask the deploy result */ }
|
|
335
|
+
|
|
336
|
+
if (/COMPLETE$/.test(result.stackStatus) && !/ROLLBACK/.test(result.stackStatus)) {
|
|
337
|
+
logger.ok(`Stack ${stackName} → ${result.stackStatus}`);
|
|
338
|
+
const outs = result.stack?.Outputs ?? [];
|
|
339
|
+
if (outs.length) {
|
|
340
|
+
logger.info('Outputs:');
|
|
341
|
+
for (const o of outs) {
|
|
342
|
+
logger.info(` ${(o.OutputKey ?? '').padEnd(30)} = ${o.OutputValue ?? ''}`);
|
|
343
|
+
}
|
|
344
|
+
highlightEndpoints(outs);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
logger.error(`Stack ${stackName} → ${result.stackStatus}. Use /rollback to investigate.`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// D2 (post-deploy verification): a clean COMPLETE means CloudFormation finished,
|
|
353
|
+
// not that the app works. Surface endpoint-like outputs so the operator has
|
|
354
|
+
// something concrete to hit before calling the deploy done. Heuristic on the
|
|
355
|
+
// OutputKey name — intentionally loose; a false highlight costs nothing.
|
|
356
|
+
// Keep in sync with the canonical export suffixes in rules/cfn-standards.md
|
|
357
|
+
// (-id, -arn, -name, -dns, -uri) — 'uri' in particular is the convention's own
|
|
358
|
+
// endpoint suffix.
|
|
359
|
+
const ENDPOINT_KEY_RE = /(url|uri|endpoint|dns|domain|address|hostname|fqdn)/i;
|
|
360
|
+
|
|
361
|
+
export function highlightEndpoints(outs) {
|
|
362
|
+
const endpoints = (outs ?? []).filter((o) => ENDPOINT_KEY_RE.test(o.OutputKey ?? '') && o.OutputValue);
|
|
363
|
+
if (endpoints.length === 0) return;
|
|
364
|
+
logger.info('');
|
|
365
|
+
logger.info('Endpoints to verify:');
|
|
366
|
+
for (const o of endpoints) logger.info(` ${chalk.cyan(o.OutputValue)} ${chalk.gray(`(${o.OutputKey})`)}`);
|
|
367
|
+
logger.info(chalk.gray('A clean stack status means CloudFormation finished — confirm each endpoint actually responds before considering the deploy done.'));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const EVENT_COLOR = {
|
|
371
|
+
CREATE_COMPLETE: chalk.green,
|
|
372
|
+
UPDATE_COMPLETE: chalk.green,
|
|
373
|
+
CREATE_IN_PROGRESS: chalk.cyan,
|
|
374
|
+
UPDATE_IN_PROGRESS: chalk.cyan,
|
|
375
|
+
DELETE_COMPLETE: chalk.gray,
|
|
376
|
+
DELETE_IN_PROGRESS: chalk.gray,
|
|
377
|
+
CREATE_FAILED: chalk.red,
|
|
378
|
+
UPDATE_FAILED: chalk.red,
|
|
379
|
+
DELETE_FAILED: chalk.red,
|
|
380
|
+
ROLLBACK_IN_PROGRESS: chalk.yellow,
|
|
381
|
+
ROLLBACK_COMPLETE: chalk.yellow,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function renderChangeTable(buckets) {
|
|
385
|
+
logger.info('');
|
|
386
|
+
logger.info('Change-set summary:');
|
|
387
|
+
logger.info(` ${chalk.green('+ Add')} ${buckets.add.length}`);
|
|
388
|
+
logger.info(` ${chalk.cyan('~ Modify')} ${buckets.modify.length}`);
|
|
389
|
+
logger.info(` ${chalk.yellow('! Replace')} ${buckets.replace.length}`);
|
|
390
|
+
logger.info(` ${chalk.red('- Remove')} ${buckets.remove.length}`);
|
|
391
|
+
|
|
392
|
+
if (buckets.dataResourceReplace.length > 0) {
|
|
393
|
+
logger.info('');
|
|
394
|
+
logger.warn('Data-resource Replace (DESTRUCTIVE):');
|
|
395
|
+
for (const r of buckets.dataResourceReplace) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
396
|
+
}
|
|
397
|
+
printBucket('Add', buckets.add, chalk.green, '+');
|
|
398
|
+
printBucket('Modify', buckets.modify, chalk.cyan, '~', true);
|
|
399
|
+
printBucket('Replace', buckets.replace, chalk.yellow, '!');
|
|
400
|
+
printBucket('Remove', buckets.remove, chalk.red, '-');
|
|
401
|
+
logger.info('');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function printBucket(label, items, color, marker, showProps = false) {
|
|
405
|
+
if (items.length === 0) return;
|
|
406
|
+
logger.info('');
|
|
407
|
+
logger.info(`${label} (${items.length}):`);
|
|
408
|
+
for (const r of items) {
|
|
409
|
+
const props = showProps && r.detailsTargets.length ? ` props=[${r.detailsTargets.join(', ')}]` : '';
|
|
410
|
+
const repl = r.replacement && r.replacement !== 'False' ? ` replacement=${r.replacement}` : '';
|
|
411
|
+
logger.info(` ${color(marker)} ${r.logicalId} (${r.resourceType})${repl}${props}`);
|
|
412
|
+
}
|
|
413
|
+
}
|