@go-to-k/cdkd 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -6
- package/dist/cli.js +658 -221
- package/dist/cli.js.map +4 -4
- package/dist/go-to-k-cdkd-0.8.0.tgz +0 -0
- package/dist/index.js +440 -153
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/dist/go-to-k-cdkd-0.6.0.tgz +0 -0
package/dist/cli.js
CHANGED
|
@@ -2401,7 +2401,9 @@ function toYaml(obj, indent = 0) {
|
|
|
2401
2401
|
let result = "\n";
|
|
2402
2402
|
for (const [key, value] of entries) {
|
|
2403
2403
|
const safeKey = key.includes(" ") ? `"${key}"` : key;
|
|
2404
|
-
|
|
2404
|
+
const isContainer = typeof value === "object" && value !== null;
|
|
2405
|
+
const isEmptyContainer = isContainer && (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
|
2406
|
+
if (isContainer && !isEmptyContainer) {
|
|
2405
2407
|
result += `${prefix}${safeKey}:${toYaml(value, indent + 1)}`;
|
|
2406
2408
|
} else {
|
|
2407
2409
|
result += `${prefix}${safeKey}: ${toYaml(value, indent + 1).trimStart()}`;
|
|
@@ -2584,17 +2586,20 @@ async function listCommand(patterns, options) {
|
|
|
2584
2586
|
}
|
|
2585
2587
|
if (options.showDependencies) {
|
|
2586
2588
|
const records = sorted.map((s) => ({
|
|
2587
|
-
id: s
|
|
2589
|
+
id: formatDisplayId(s),
|
|
2588
2590
|
dependencies: [...s.dependencyNames]
|
|
2589
2591
|
}));
|
|
2590
2592
|
emitStructured(records, options.json);
|
|
2591
2593
|
return;
|
|
2592
2594
|
}
|
|
2593
2595
|
for (const stack of sorted) {
|
|
2594
|
-
process.stdout.write(`${stack
|
|
2596
|
+
process.stdout.write(`${formatDisplayId(stack)}
|
|
2595
2597
|
`);
|
|
2596
2598
|
}
|
|
2597
2599
|
}
|
|
2600
|
+
function formatDisplayId(stack) {
|
|
2601
|
+
return stack.displayName === stack.stackName ? stack.displayName : `${stack.displayName} (${stack.stackName})`;
|
|
2602
|
+
}
|
|
2598
2603
|
function emitStructured(payload, asJson) {
|
|
2599
2604
|
if (asJson) {
|
|
2600
2605
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
@@ -3200,6 +3205,14 @@ import {
|
|
|
3200
3205
|
ListObjectsV2Command,
|
|
3201
3206
|
NoSuchKey
|
|
3202
3207
|
} from "@aws-sdk/client-s3";
|
|
3208
|
+
|
|
3209
|
+
// src/types/state.ts
|
|
3210
|
+
var STATE_SCHEMA_VERSION_LEGACY = 1;
|
|
3211
|
+
var STATE_SCHEMA_VERSION_CURRENT = 2;
|
|
3212
|
+
|
|
3213
|
+
// src/state/s3-state-backend.ts
|
|
3214
|
+
var LEGACY_KEY_DEPTH = 2;
|
|
3215
|
+
var NEW_KEY_DEPTH = 3;
|
|
3203
3216
|
var S3StateBackend = class {
|
|
3204
3217
|
constructor(s3Client, config) {
|
|
3205
3218
|
this.s3Client = s3Client;
|
|
@@ -3207,9 +3220,16 @@ var S3StateBackend = class {
|
|
|
3207
3220
|
}
|
|
3208
3221
|
logger = getLogger().child("S3StateBackend");
|
|
3209
3222
|
/**
|
|
3210
|
-
* Get the S3 key for a stack's state file
|
|
3223
|
+
* Get the new (region-scoped) S3 key for a stack's state file.
|
|
3211
3224
|
*/
|
|
3212
|
-
getStateKey(stackName) {
|
|
3225
|
+
getStateKey(stackName, region) {
|
|
3226
|
+
return `${this.config.prefix}/${stackName}/${region}/state.json`;
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Get the legacy (pre-region-prefix) S3 key for a stack's state file.
|
|
3230
|
+
* Used for backwards-compatible reads and for the migration delete.
|
|
3231
|
+
*/
|
|
3232
|
+
getLegacyStateKey(stackName) {
|
|
3213
3233
|
return `${this.config.prefix}/${stackName}/state.json`;
|
|
3214
3234
|
}
|
|
3215
3235
|
/**
|
|
@@ -3235,101 +3255,143 @@ var S3StateBackend = class {
|
|
|
3235
3255
|
}
|
|
3236
3256
|
}
|
|
3237
3257
|
/**
|
|
3238
|
-
* Check if state exists for a stack
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
);
|
|
3258
|
+
* Check if state exists for a stack in the given region.
|
|
3259
|
+
*
|
|
3260
|
+
* Returns true for either layout: the new region-scoped key, or the legacy
|
|
3261
|
+
* key when its embedded `region` matches the requested region. This lets
|
|
3262
|
+
* `cdkd state rm <stack> --region X` and `cdkd destroy <stack>` see legacy
|
|
3263
|
+
* state without forcing a write-through migration first.
|
|
3264
|
+
*/
|
|
3265
|
+
async stateExists(stackName, region) {
|
|
3266
|
+
const newKey = this.getStateKey(stackName, region);
|
|
3267
|
+
if (await this.headObject(newKey)) {
|
|
3249
3268
|
return true;
|
|
3250
|
-
} catch (error) {
|
|
3251
|
-
if (error instanceof NoSuchKey || error.name === "NotFound") {
|
|
3252
|
-
return false;
|
|
3253
|
-
}
|
|
3254
|
-
throw new StateError(
|
|
3255
|
-
`Failed to check if state exists for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3256
|
-
error instanceof Error ? error : void 0
|
|
3257
|
-
);
|
|
3258
3269
|
}
|
|
3270
|
+
return this.legacyMatchesRegion(stackName, region);
|
|
3259
3271
|
}
|
|
3260
3272
|
/**
|
|
3261
|
-
* Get state for a stack
|
|
3273
|
+
* Get state for a stack, transparently falling back to the legacy key.
|
|
3262
3274
|
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3275
|
+
* Lookup order:
|
|
3276
|
+
* 1. `{prefix}/{stackName}/{region}/state.json` (current `version: 2` key).
|
|
3277
|
+
* 2. `{prefix}/{stackName}/state.json` (legacy `version: 1` key) — only
|
|
3278
|
+
* accepted if its embedded `region` matches the requested region.
|
|
3279
|
+
*
|
|
3280
|
+
* When a legacy hit is returned, `migrationPending` is `true`. Callers that
|
|
3281
|
+
* subsequently `saveState` automatically migrate by writing the new key and
|
|
3282
|
+
* deleting the legacy one (see `saveState`'s `legacyMigration` argument).
|
|
3283
|
+
*
|
|
3284
|
+
* Note: S3 returns ETag with surrounding quotes (e.g., `"abc123"`). We
|
|
3285
|
+
* preserve the quotes — they are required for `IfMatch` conditions.
|
|
3265
3286
|
*/
|
|
3266
|
-
async getState(stackName) {
|
|
3267
|
-
const
|
|
3287
|
+
async getState(stackName, region) {
|
|
3288
|
+
const newKey = this.getStateKey(stackName, region);
|
|
3268
3289
|
try {
|
|
3269
|
-
this.logger.debug(`Getting state for stack: ${stackName}`);
|
|
3290
|
+
this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
|
|
3270
3291
|
const response = await this.s3Client.send(
|
|
3271
3292
|
new GetObjectCommand({
|
|
3272
3293
|
Bucket: this.config.bucket,
|
|
3273
|
-
Key:
|
|
3294
|
+
Key: newKey
|
|
3274
3295
|
})
|
|
3275
3296
|
);
|
|
3276
3297
|
if (!response.Body) {
|
|
3277
|
-
throw new StateError(`State file for stack '${stackName}' has no body`);
|
|
3298
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no body`);
|
|
3278
3299
|
}
|
|
3279
3300
|
if (!response.ETag) {
|
|
3280
|
-
throw new StateError(`State file for stack '${stackName}' has no ETag`);
|
|
3301
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no ETag`);
|
|
3281
3302
|
}
|
|
3282
3303
|
const bodyString = await response.Body.transformToString();
|
|
3283
|
-
const state =
|
|
3284
|
-
this.logger.debug(`Retrieved state
|
|
3285
|
-
return {
|
|
3286
|
-
state,
|
|
3287
|
-
etag: response.ETag
|
|
3288
|
-
};
|
|
3304
|
+
const state = this.parseStateBody(bodyString, stackName);
|
|
3305
|
+
this.logger.debug(`Retrieved state: ${stackName} (${region}), ETag: ${response.ETag}`);
|
|
3306
|
+
return { state, etag: response.ETag };
|
|
3289
3307
|
} catch (error) {
|
|
3290
|
-
if (error
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3308
|
+
if (!isNoSuchKey(error)) {
|
|
3309
|
+
if (error instanceof StateError)
|
|
3310
|
+
throw error;
|
|
3311
|
+
throw new StateError(
|
|
3312
|
+
`Failed to get state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3313
|
+
error instanceof Error ? error : void 0
|
|
3314
|
+
);
|
|
3296
3315
|
}
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3316
|
+
this.logger.debug(`No state at new key for stack: ${stackName} (${region})`);
|
|
3317
|
+
}
|
|
3318
|
+
const legacy = await this.tryGetLegacy(stackName, region);
|
|
3319
|
+
if (legacy) {
|
|
3320
|
+
this.logger.warn(
|
|
3321
|
+
`Loaded legacy state for stack '${stackName}' from '${this.getLegacyStateKey(stackName)}'. It will be migrated to the region-scoped layout on next save.`
|
|
3300
3322
|
);
|
|
3323
|
+
return { ...legacy, migrationPending: true };
|
|
3301
3324
|
}
|
|
3325
|
+
return null;
|
|
3302
3326
|
}
|
|
3303
3327
|
/**
|
|
3304
|
-
* Save state for a stack with optimistic locking
|
|
3328
|
+
* Save state for a stack with optimistic locking.
|
|
3329
|
+
*
|
|
3330
|
+
* Always writes to the new region-scoped key. The state body is rewritten
|
|
3331
|
+
* with `version: 2` and the supplied region.
|
|
3332
|
+
*
|
|
3333
|
+
* If the caller observed `migrationPending: true` from `getState`, it
|
|
3334
|
+
* should pass the legacy ETag back via `expectedEtag` AND set
|
|
3335
|
+
* `migrateLegacy: true`. After the new key is written successfully, the
|
|
3336
|
+
* legacy key is deleted to complete migration. The legacy delete is a
|
|
3337
|
+
* best-effort follow-up — a failure is logged but does not unwind the new
|
|
3338
|
+
* write.
|
|
3305
3339
|
*
|
|
3306
3340
|
* @param stackName Stack name
|
|
3341
|
+
* @param region Target region (load-bearing — part of the S3 key)
|
|
3307
3342
|
* @param state State to save
|
|
3308
|
-
* @param
|
|
3309
|
-
*
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
const
|
|
3343
|
+
* @param options Optimistic-lock ETag + legacy-migration flag
|
|
3344
|
+
* @returns New ETag (with quotes, e.g., `"abc123"`)
|
|
3345
|
+
*/
|
|
3346
|
+
async saveState(stackName, region, state, options = {}) {
|
|
3347
|
+
const newKey = this.getStateKey(stackName, region);
|
|
3348
|
+
const { expectedEtag, migrateLegacy } = options;
|
|
3349
|
+
const body = {
|
|
3350
|
+
...state,
|
|
3351
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
3352
|
+
stackName,
|
|
3353
|
+
region
|
|
3354
|
+
};
|
|
3314
3355
|
try {
|
|
3315
3356
|
this.logger.debug(
|
|
3316
|
-
`Saving state
|
|
3357
|
+
`Saving state: ${stackName} (${region})${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
|
|
3317
3358
|
);
|
|
3318
|
-
const
|
|
3359
|
+
const bodyString = JSON.stringify(body, null, 2);
|
|
3319
3360
|
const response = await this.s3Client.send(
|
|
3320
3361
|
new PutObjectCommand2({
|
|
3321
3362
|
Bucket: this.config.bucket,
|
|
3322
|
-
Key:
|
|
3323
|
-
Body:
|
|
3324
|
-
ContentLength: Buffer.byteLength(
|
|
3363
|
+
Key: newKey,
|
|
3364
|
+
Body: bodyString,
|
|
3365
|
+
ContentLength: Buffer.byteLength(bodyString),
|
|
3325
3366
|
ContentType: "application/json",
|
|
3326
|
-
|
|
3367
|
+
// The legacy ETag is for a different key; only forward it when we're
|
|
3368
|
+
// updating in-place at the new key.
|
|
3369
|
+
...!migrateLegacy && expectedEtag && { IfMatch: expectedEtag }
|
|
3327
3370
|
})
|
|
3328
3371
|
);
|
|
3329
3372
|
if (!response.ETag) {
|
|
3330
|
-
throw new StateError(
|
|
3373
|
+
throw new StateError(
|
|
3374
|
+
`No ETag returned after saving state for stack '${stackName}' (${region})`
|
|
3375
|
+
);
|
|
3376
|
+
}
|
|
3377
|
+
this.logger.debug(`State saved: ${stackName} (${region}), new ETag: ${response.ETag}`);
|
|
3378
|
+
if (migrateLegacy) {
|
|
3379
|
+
try {
|
|
3380
|
+
await this.s3Client.send(
|
|
3381
|
+
new DeleteObjectCommand({
|
|
3382
|
+
Bucket: this.config.bucket,
|
|
3383
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3384
|
+
})
|
|
3385
|
+
);
|
|
3386
|
+
this.logger.info(
|
|
3387
|
+
`Migrated state for stack '${stackName}' to region-scoped layout (${region})`
|
|
3388
|
+
);
|
|
3389
|
+
} catch (deleteError) {
|
|
3390
|
+
this.logger.warn(
|
|
3391
|
+
`Migrated stack '${stackName}' to new key, but failed to delete legacy key: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3331
3394
|
}
|
|
3332
|
-
this.logger.debug(`State saved for stack: ${stackName}, new ETag: ${response.ETag}`);
|
|
3333
3395
|
return response.ETag;
|
|
3334
3396
|
} catch (error) {
|
|
3335
3397
|
if (error.name === "PreconditionFailed") {
|
|
@@ -3338,63 +3400,230 @@ var S3StateBackend = class {
|
|
|
3338
3400
|
);
|
|
3339
3401
|
}
|
|
3340
3402
|
throw new StateError(
|
|
3341
|
-
`Failed to save state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3403
|
+
`Failed to save state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3342
3404
|
error instanceof Error ? error : void 0
|
|
3343
3405
|
);
|
|
3344
3406
|
}
|
|
3345
3407
|
}
|
|
3346
3408
|
/**
|
|
3347
|
-
* Delete state for a stack
|
|
3409
|
+
* Delete state for a stack in the given region.
|
|
3410
|
+
*
|
|
3411
|
+
* Removes both the new key and the legacy key (if present). Legacy removal
|
|
3412
|
+
* is region-conditional: a legacy state file with a different `region`
|
|
3413
|
+
* field is left alone.
|
|
3348
3414
|
*/
|
|
3349
|
-
async deleteState(stackName) {
|
|
3350
|
-
const key = this.getStateKey(stackName);
|
|
3415
|
+
async deleteState(stackName, region) {
|
|
3351
3416
|
try {
|
|
3352
|
-
this.logger.debug(`Deleting state
|
|
3417
|
+
this.logger.debug(`Deleting state: ${stackName} (${region})`);
|
|
3353
3418
|
await this.s3Client.send(
|
|
3354
3419
|
new DeleteObjectCommand({
|
|
3355
3420
|
Bucket: this.config.bucket,
|
|
3356
|
-
Key:
|
|
3421
|
+
Key: this.getStateKey(stackName, region)
|
|
3357
3422
|
})
|
|
3358
3423
|
);
|
|
3359
|
-
this.
|
|
3424
|
+
if (await this.legacyMatchesRegion(stackName, region)) {
|
|
3425
|
+
await this.s3Client.send(
|
|
3426
|
+
new DeleteObjectCommand({
|
|
3427
|
+
Bucket: this.config.bucket,
|
|
3428
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3429
|
+
})
|
|
3430
|
+
);
|
|
3431
|
+
this.logger.debug(`Deleted legacy state for stack: ${stackName}`);
|
|
3432
|
+
}
|
|
3433
|
+
this.logger.debug(`State deleted: ${stackName} (${region})`);
|
|
3360
3434
|
} catch (error) {
|
|
3361
3435
|
throw new StateError(
|
|
3362
|
-
`Failed to delete state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3436
|
+
`Failed to delete state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3363
3437
|
error instanceof Error ? error : void 0
|
|
3364
3438
|
);
|
|
3365
3439
|
}
|
|
3366
3440
|
}
|
|
3367
3441
|
/**
|
|
3368
|
-
* List all stacks with state
|
|
3442
|
+
* List all stacks with state in the bucket.
|
|
3443
|
+
*
|
|
3444
|
+
* Returns one `{stackName, region}` pair per state file. Both layouts
|
|
3445
|
+
* are enumerated:
|
|
3446
|
+
*
|
|
3447
|
+
* - `{prefix}/{stackName}/{region}/state.json` (new) — `region` is the
|
|
3448
|
+
* path segment.
|
|
3449
|
+
* - `{prefix}/{stackName}/state.json` (legacy) — `region` is read from the
|
|
3450
|
+
* state body when present, otherwise `undefined`.
|
|
3451
|
+
*
|
|
3452
|
+
* Pairs are deduplicated by `(stackName, region)` so a stack mid-migration
|
|
3453
|
+
* shows up exactly once.
|
|
3369
3454
|
*/
|
|
3370
3455
|
async listStacks() {
|
|
3371
3456
|
try {
|
|
3372
3457
|
this.logger.debug("Listing all stacks");
|
|
3458
|
+
const prefix = `${this.config.prefix}/`;
|
|
3459
|
+
const refs = [];
|
|
3460
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3461
|
+
let continuationToken;
|
|
3462
|
+
do {
|
|
3463
|
+
const response = await this.s3Client.send(
|
|
3464
|
+
new ListObjectsV2Command({
|
|
3465
|
+
Bucket: this.config.bucket,
|
|
3466
|
+
Prefix: prefix,
|
|
3467
|
+
...continuationToken && { ContinuationToken: continuationToken }
|
|
3468
|
+
})
|
|
3469
|
+
);
|
|
3470
|
+
for (const obj of response.Contents ?? []) {
|
|
3471
|
+
const key = obj.Key;
|
|
3472
|
+
if (!key)
|
|
3473
|
+
continue;
|
|
3474
|
+
if (!key.endsWith("/state.json"))
|
|
3475
|
+
continue;
|
|
3476
|
+
const rest = key.slice(prefix.length);
|
|
3477
|
+
const segments = rest.split("/");
|
|
3478
|
+
if (segments.length === NEW_KEY_DEPTH) {
|
|
3479
|
+
const [stackName, region] = segments;
|
|
3480
|
+
if (!stackName || !region)
|
|
3481
|
+
continue;
|
|
3482
|
+
const dedupeKey = `${stackName}\0${region}`;
|
|
3483
|
+
if (!seen.has(dedupeKey)) {
|
|
3484
|
+
seen.add(dedupeKey);
|
|
3485
|
+
refs.push({ stackName, region });
|
|
3486
|
+
}
|
|
3487
|
+
continue;
|
|
3488
|
+
}
|
|
3489
|
+
if (segments.length === LEGACY_KEY_DEPTH) {
|
|
3490
|
+
const [stackName] = segments;
|
|
3491
|
+
if (!stackName)
|
|
3492
|
+
continue;
|
|
3493
|
+
const region = await this.readLegacyRegion(stackName);
|
|
3494
|
+
const dedupeKey = `${stackName}\0${region ?? ""}`;
|
|
3495
|
+
if (!seen.has(dedupeKey)) {
|
|
3496
|
+
seen.add(dedupeKey);
|
|
3497
|
+
refs.push({ stackName, ...region ? { region } : {} });
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
3502
|
+
} while (continuationToken);
|
|
3503
|
+
this.logger.debug(`Found ${refs.length} stack(s) across regions`);
|
|
3504
|
+
return refs;
|
|
3505
|
+
} catch (error) {
|
|
3506
|
+
throw new StateError(
|
|
3507
|
+
`Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
|
|
3508
|
+
error instanceof Error ? error : void 0
|
|
3509
|
+
);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* HeadObject probe — returns true on 200, false on NotFound. Other errors
|
|
3514
|
+
* propagate so we don't accidentally swallow IAM denials.
|
|
3515
|
+
*/
|
|
3516
|
+
async headObject(key) {
|
|
3517
|
+
try {
|
|
3518
|
+
await this.s3Client.send(
|
|
3519
|
+
new HeadObjectCommand2({
|
|
3520
|
+
Bucket: this.config.bucket,
|
|
3521
|
+
Key: key
|
|
3522
|
+
})
|
|
3523
|
+
);
|
|
3524
|
+
return true;
|
|
3525
|
+
} catch (error) {
|
|
3526
|
+
if (isNoSuchKey(error) || error.name === "NotFound") {
|
|
3527
|
+
return false;
|
|
3528
|
+
}
|
|
3529
|
+
throw error;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Read the legacy state's `region` field. Used for region matching during
|
|
3534
|
+
* `stateExists` / `deleteState` and for assigning a region to legacy
|
|
3535
|
+
* entries during `listStacks`.
|
|
3536
|
+
*/
|
|
3537
|
+
async readLegacyRegion(stackName) {
|
|
3538
|
+
try {
|
|
3373
3539
|
const response = await this.s3Client.send(
|
|
3374
|
-
new
|
|
3540
|
+
new GetObjectCommand({
|
|
3375
3541
|
Bucket: this.config.bucket,
|
|
3376
|
-
|
|
3377
|
-
Delimiter: "/"
|
|
3542
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3378
3543
|
})
|
|
3379
3544
|
);
|
|
3380
|
-
if (!response.
|
|
3381
|
-
return
|
|
3545
|
+
if (!response.Body)
|
|
3546
|
+
return void 0;
|
|
3547
|
+
const bodyString = await response.Body.transformToString();
|
|
3548
|
+
const state = JSON.parse(bodyString);
|
|
3549
|
+
return typeof state.region === "string" ? state.region : void 0;
|
|
3550
|
+
} catch (error) {
|
|
3551
|
+
if (isNoSuchKey(error))
|
|
3552
|
+
return void 0;
|
|
3553
|
+
this.logger.debug(
|
|
3554
|
+
`Could not read legacy state region for '${stackName}': ${error instanceof Error ? error.message : String(error)}`
|
|
3555
|
+
);
|
|
3556
|
+
return void 0;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
async legacyMatchesRegion(stackName, region) {
|
|
3560
|
+
const legacyRegion = await this.readLegacyRegion(stackName);
|
|
3561
|
+
return legacyRegion === region;
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Try to read the legacy `version: 1` state. Returns null when the legacy
|
|
3565
|
+
* key is missing or its embedded region does not match the caller's region.
|
|
3566
|
+
*/
|
|
3567
|
+
async tryGetLegacy(stackName, region) {
|
|
3568
|
+
try {
|
|
3569
|
+
const response = await this.s3Client.send(
|
|
3570
|
+
new GetObjectCommand({
|
|
3571
|
+
Bucket: this.config.bucket,
|
|
3572
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3573
|
+
})
|
|
3574
|
+
);
|
|
3575
|
+
if (!response.Body || !response.ETag) {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
const bodyString = await response.Body.transformToString();
|
|
3579
|
+
const state = this.parseStateBody(bodyString, stackName);
|
|
3580
|
+
if (state.region && state.region !== region) {
|
|
3581
|
+
this.logger.debug(
|
|
3582
|
+
`Legacy state for stack '${stackName}' has region '${state.region}', not '${region}' \u2014 skipping legacy fallback.`
|
|
3583
|
+
);
|
|
3584
|
+
return null;
|
|
3382
3585
|
}
|
|
3383
|
-
|
|
3384
|
-
const prefixStr = prefix.Prefix || "";
|
|
3385
|
-
const parts = prefixStr.split("/");
|
|
3386
|
-
return parts[parts.length - 2];
|
|
3387
|
-
}).filter((name) => Boolean(name));
|
|
3388
|
-
this.logger.debug(`Found ${stackNames.length} stacks`);
|
|
3389
|
-
return stackNames;
|
|
3586
|
+
return { state, etag: response.ETag };
|
|
3390
3587
|
} catch (error) {
|
|
3588
|
+
if (isNoSuchKey(error))
|
|
3589
|
+
return null;
|
|
3391
3590
|
throw new StateError(
|
|
3392
|
-
`Failed to
|
|
3591
|
+
`Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3592
|
+
error instanceof Error ? error : void 0
|
|
3593
|
+
);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Parse a state body and validate the schema version. Future-proofs against
|
|
3598
|
+
* a binary that predates schema version `N` reading a `version: N+1` blob:
|
|
3599
|
+
* the old binary would otherwise treat unknown fields as defaults and
|
|
3600
|
+
* silently lose data on the next save.
|
|
3601
|
+
*/
|
|
3602
|
+
parseStateBody(bodyString, stackName) {
|
|
3603
|
+
let parsed;
|
|
3604
|
+
try {
|
|
3605
|
+
parsed = JSON.parse(bodyString);
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
throw new StateError(
|
|
3608
|
+
`State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
3393
3609
|
error instanceof Error ? error : void 0
|
|
3394
3610
|
);
|
|
3395
3611
|
}
|
|
3612
|
+
const v = parsed.version;
|
|
3613
|
+
if (v !== STATE_SCHEMA_VERSION_LEGACY && v !== STATE_SCHEMA_VERSION_CURRENT && v !== void 0) {
|
|
3614
|
+
throw new StateError(
|
|
3615
|
+
`Unsupported state schema version ${String(v)} for stack '${stackName}'. This cdkd binary supports versions ${String(STATE_SCHEMA_VERSION_LEGACY)} and ${String(STATE_SCHEMA_VERSION_CURRENT)}. Upgrade cdkd to a version that supports schema ${String(v)}.`
|
|
3616
|
+
);
|
|
3617
|
+
}
|
|
3618
|
+
return parsed;
|
|
3396
3619
|
}
|
|
3397
3620
|
};
|
|
3621
|
+
function isNoSuchKey(error) {
|
|
3622
|
+
if (error instanceof NoSuchKey)
|
|
3623
|
+
return true;
|
|
3624
|
+
const name = error?.name;
|
|
3625
|
+
return name === "NoSuchKey";
|
|
3626
|
+
}
|
|
3398
3627
|
|
|
3399
3628
|
// src/state/lock-manager.ts
|
|
3400
3629
|
import {
|
|
@@ -3415,10 +3644,24 @@ var LockManager = class {
|
|
|
3415
3644
|
logger = getLogger().child("LockManager");
|
|
3416
3645
|
ttlMs;
|
|
3417
3646
|
/**
|
|
3418
|
-
* Get the S3 key for a stack's lock file
|
|
3647
|
+
* Get the S3 key for a stack's lock file.
|
|
3648
|
+
*
|
|
3649
|
+
* Locks are region-scoped, mirroring the state key layout
|
|
3650
|
+
* (`{prefix}/{stackName}/{region}/lock.json`). Two regions of the same
|
|
3651
|
+
* stackName can therefore be operated on in parallel without contention,
|
|
3652
|
+
* matching cdkd's parallel execution model.
|
|
3653
|
+
*
|
|
3654
|
+
* The `region` argument is required for new callers; for backwards
|
|
3655
|
+
* compatibility with `state list --long` (which only sees stack names),
|
|
3656
|
+
* passing `undefined` falls back to the legacy `{prefix}/{stackName}/lock.json`
|
|
3657
|
+
* key — that mode is purely for legacy lock cleanup and is NOT used by
|
|
3658
|
+
* deploy / destroy / diff anymore.
|
|
3419
3659
|
*/
|
|
3420
|
-
getLockKey(stackName) {
|
|
3421
|
-
|
|
3660
|
+
getLockKey(stackName, region) {
|
|
3661
|
+
if (region === void 0) {
|
|
3662
|
+
return `${this.config.prefix}/${stackName}/lock.json`;
|
|
3663
|
+
}
|
|
3664
|
+
return `${this.config.prefix}/${stackName}/${region}/lock.json`;
|
|
3422
3665
|
}
|
|
3423
3666
|
/**
|
|
3424
3667
|
* Get default lock owner identifier
|
|
@@ -3457,11 +3700,12 @@ var LockManager = class {
|
|
|
3457
3700
|
* If an expired lock exists, it will be cleaned up and re-acquired.
|
|
3458
3701
|
*
|
|
3459
3702
|
* @param stackName Stack name
|
|
3703
|
+
* @param region Target region (lock key is region-scoped)
|
|
3460
3704
|
* @param owner Lock owner identifier (defaults to user@hostname:pid)
|
|
3461
3705
|
* @param operation Operation being performed (e.g., "deploy", "destroy")
|
|
3462
3706
|
*/
|
|
3463
|
-
async acquireLock(stackName, owner, operation) {
|
|
3464
|
-
const key = this.getLockKey(stackName);
|
|
3707
|
+
async acquireLock(stackName, region, owner, operation) {
|
|
3708
|
+
const key = this.getLockKey(stackName, region);
|
|
3465
3709
|
const lockOwner = owner || this.getDefaultOwner();
|
|
3466
3710
|
const now = Date.now();
|
|
3467
3711
|
const lockInfo = {
|
|
@@ -3471,7 +3715,7 @@ var LockManager = class {
|
|
|
3471
3715
|
...operation && { operation }
|
|
3472
3716
|
};
|
|
3473
3717
|
try {
|
|
3474
|
-
this.logger.debug(`Attempting to acquire lock for stack: ${stackName}`);
|
|
3718
|
+
this.logger.debug(`Attempting to acquire lock for stack: ${stackName} (${region})`);
|
|
3475
3719
|
const lockBody = JSON.stringify(lockInfo, null, 2);
|
|
3476
3720
|
await this.s3Client.send(
|
|
3477
3721
|
new PutObjectCommand3({
|
|
@@ -3484,17 +3728,17 @@ var LockManager = class {
|
|
|
3484
3728
|
// Only succeed if object doesn't exist
|
|
3485
3729
|
})
|
|
3486
3730
|
);
|
|
3487
|
-
this.logger.debug(`Lock acquired for stack: ${stackName}, owner: ${lockOwner}`);
|
|
3731
|
+
this.logger.debug(`Lock acquired for stack: ${stackName} (${region}), owner: ${lockOwner}`);
|
|
3488
3732
|
return true;
|
|
3489
3733
|
} catch (error) {
|
|
3490
3734
|
if (error instanceof S3ServiceException && error.name === "PreconditionFailed") {
|
|
3491
|
-
this.logger.debug(`Lock already exists for stack: ${stackName}`);
|
|
3492
|
-
const existingLock = await this.getLockInfo(stackName);
|
|
3735
|
+
this.logger.debug(`Lock already exists for stack: ${stackName} (${region})`);
|
|
3736
|
+
const existingLock = await this.getLockInfo(stackName, region);
|
|
3493
3737
|
if (existingLock && this.isLockExpired(existingLock)) {
|
|
3494
3738
|
this.logger.info(
|
|
3495
|
-
`Expired lock detected for stack: ${stackName} (owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
|
|
3739
|
+
`Expired lock detected for stack: ${stackName} (${region}, owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
|
|
3496
3740
|
);
|
|
3497
|
-
await this.deleteLock(stackName);
|
|
3741
|
+
await this.deleteLock(stackName, region);
|
|
3498
3742
|
try {
|
|
3499
3743
|
const retryBody = JSON.stringify(lockInfo, null, 2);
|
|
3500
3744
|
await this.s3Client.send(
|
|
@@ -3508,13 +3752,13 @@ var LockManager = class {
|
|
|
3508
3752
|
})
|
|
3509
3753
|
);
|
|
3510
3754
|
this.logger.debug(
|
|
3511
|
-
`Lock acquired for stack: ${stackName} after expired lock cleanup, owner: ${lockOwner}`
|
|
3755
|
+
`Lock acquired for stack: ${stackName} (${region}) after expired lock cleanup, owner: ${lockOwner}`
|
|
3512
3756
|
);
|
|
3513
3757
|
return true;
|
|
3514
3758
|
} catch (retryError) {
|
|
3515
3759
|
if (retryError instanceof S3ServiceException && retryError.name === "PreconditionFailed") {
|
|
3516
3760
|
this.logger.debug(
|
|
3517
|
-
`Lock was acquired by another process during expired lock cleanup for stack: ${stackName}`
|
|
3761
|
+
`Lock was acquired by another process during expired lock cleanup for stack: ${stackName} (${region})`
|
|
3518
3762
|
);
|
|
3519
3763
|
return false;
|
|
3520
3764
|
}
|
|
@@ -3524,16 +3768,20 @@ var LockManager = class {
|
|
|
3524
3768
|
return false;
|
|
3525
3769
|
}
|
|
3526
3770
|
throw new LockError(
|
|
3527
|
-
`Failed to acquire lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3771
|
+
`Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3528
3772
|
error instanceof Error ? error : void 0
|
|
3529
3773
|
);
|
|
3530
3774
|
}
|
|
3531
3775
|
}
|
|
3532
3776
|
/**
|
|
3533
|
-
* Get current lock information
|
|
3777
|
+
* Get current lock information.
|
|
3778
|
+
*
|
|
3779
|
+
* `region` is required for the new region-scoped lock layout. Pass
|
|
3780
|
+
* `undefined` only to inspect a legacy `{prefix}/{stackName}/lock.json`
|
|
3781
|
+
* file (e.g. for state-listing tools that don't yet know the region).
|
|
3534
3782
|
*/
|
|
3535
|
-
async getLockInfo(stackName) {
|
|
3536
|
-
const key = this.getLockKey(stackName);
|
|
3783
|
+
async getLockInfo(stackName, region) {
|
|
3784
|
+
const key = this.getLockKey(stackName, region);
|
|
3537
3785
|
try {
|
|
3538
3786
|
this.logger.debug(`Getting lock info for stack: ${stackName}`);
|
|
3539
3787
|
const response = await this.s3Client.send(
|
|
@@ -3571,27 +3819,27 @@ var LockManager = class {
|
|
|
3571
3819
|
* not for acquisition decisions — use `acquireLock` for that, which has its
|
|
3572
3820
|
* own expired-lock cleanup logic.
|
|
3573
3821
|
*/
|
|
3574
|
-
async isLocked(stackName) {
|
|
3575
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3822
|
+
async isLocked(stackName, region) {
|
|
3823
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3576
3824
|
return lockInfo !== null;
|
|
3577
3825
|
}
|
|
3578
3826
|
/**
|
|
3579
3827
|
* Release a lock for a stack
|
|
3580
3828
|
*/
|
|
3581
|
-
async releaseLock(stackName) {
|
|
3582
|
-
const key = this.getLockKey(stackName);
|
|
3829
|
+
async releaseLock(stackName, region) {
|
|
3830
|
+
const key = this.getLockKey(stackName, region);
|
|
3583
3831
|
try {
|
|
3584
|
-
this.logger.debug(`Releasing lock for stack: ${stackName}`);
|
|
3832
|
+
this.logger.debug(`Releasing lock for stack: ${stackName} (${region})`);
|
|
3585
3833
|
await this.s3Client.send(
|
|
3586
3834
|
new DeleteObjectCommand2({
|
|
3587
3835
|
Bucket: this.config.bucket,
|
|
3588
3836
|
Key: key
|
|
3589
3837
|
})
|
|
3590
3838
|
);
|
|
3591
|
-
this.logger.debug(`Lock released for stack: ${stackName}`);
|
|
3839
|
+
this.logger.debug(`Lock released for stack: ${stackName} (${region})`);
|
|
3592
3840
|
} catch (error) {
|
|
3593
3841
|
throw new LockError(
|
|
3594
|
-
`Failed to release lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3842
|
+
`Failed to release lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3595
3843
|
error instanceof Error ? error : void 0
|
|
3596
3844
|
);
|
|
3597
3845
|
}
|
|
@@ -3601,23 +3849,28 @@ var LockManager = class {
|
|
|
3601
3849
|
*
|
|
3602
3850
|
* This is intended for CLI usage (e.g., --force-unlock flag) when a lock
|
|
3603
3851
|
* is stuck and needs manual intervention.
|
|
3852
|
+
*
|
|
3853
|
+
* Pass `region: undefined` to operate on a legacy
|
|
3854
|
+
* `{prefix}/{stackName}/lock.json` file.
|
|
3604
3855
|
*/
|
|
3605
|
-
async forceReleaseLock(stackName) {
|
|
3606
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3856
|
+
async forceReleaseLock(stackName, region) {
|
|
3857
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3607
3858
|
if (!lockInfo) {
|
|
3608
|
-
this.logger.warn(
|
|
3859
|
+
this.logger.warn(
|
|
3860
|
+
`No lock to force release for stack: ${stackName}${region ? ` (${region})` : ""}`
|
|
3861
|
+
);
|
|
3609
3862
|
return;
|
|
3610
3863
|
}
|
|
3611
3864
|
this.logger.warn(
|
|
3612
|
-
`Force releasing lock for stack: ${stackName}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
|
|
3865
|
+
`Force releasing lock for stack: ${stackName}${region ? ` (${region})` : ""}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
|
|
3613
3866
|
);
|
|
3614
|
-
await this.deleteLock(stackName);
|
|
3867
|
+
await this.deleteLock(stackName, region);
|
|
3615
3868
|
}
|
|
3616
3869
|
/**
|
|
3617
3870
|
* Internal method to delete the lock file from S3
|
|
3618
3871
|
*/
|
|
3619
|
-
async deleteLock(stackName) {
|
|
3620
|
-
const key = this.getLockKey(stackName);
|
|
3872
|
+
async deleteLock(stackName, region) {
|
|
3873
|
+
const key = this.getLockKey(stackName, region);
|
|
3621
3874
|
await this.s3Client.send(
|
|
3622
3875
|
new DeleteObjectCommand2({
|
|
3623
3876
|
Bucket: this.config.bucket,
|
|
@@ -3638,28 +3891,28 @@ var LockManager = class {
|
|
|
3638
3891
|
* @param maxRetries Maximum number of retries (default: 3)
|
|
3639
3892
|
* @param retryDelay Delay between retries in milliseconds (default: 2000)
|
|
3640
3893
|
*/
|
|
3641
|
-
async acquireLockWithRetry(stackName, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3894
|
+
async acquireLockWithRetry(stackName, region, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3642
3895
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3643
|
-
const acquired = await this.acquireLock(stackName, owner, operation);
|
|
3896
|
+
const acquired = await this.acquireLock(stackName, region, owner, operation);
|
|
3644
3897
|
if (acquired) {
|
|
3645
3898
|
return;
|
|
3646
3899
|
}
|
|
3647
|
-
const lockInfo2 = await this.getLockInfo(stackName);
|
|
3900
|
+
const lockInfo2 = await this.getLockInfo(stackName, region);
|
|
3648
3901
|
if (lockInfo2) {
|
|
3649
3902
|
const remainingMs = lockInfo2.expiresAt - Date.now();
|
|
3650
3903
|
if (attempt < maxRetries) {
|
|
3651
3904
|
this.logger.info(
|
|
3652
|
-
`Stack '${stackName}' is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
|
|
3905
|
+
`Stack '${stackName}' (${region}) is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
|
|
3653
3906
|
);
|
|
3654
3907
|
await new Promise((resolve4) => setTimeout(resolve4, retryDelay));
|
|
3655
3908
|
continue;
|
|
3656
3909
|
}
|
|
3657
3910
|
}
|
|
3658
3911
|
}
|
|
3659
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3912
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3660
3913
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
3661
3914
|
throw new LockError(
|
|
3662
|
-
`Failed to acquire lock for stack '${stackName}' after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
|
|
3915
|
+
`Failed to acquire lock for stack '${stackName}' (${region}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
|
|
3663
3916
|
);
|
|
3664
3917
|
}
|
|
3665
3918
|
};
|
|
@@ -5442,35 +5695,45 @@ var IntrinsicFunctionResolver = class {
|
|
|
5442
5695
|
}
|
|
5443
5696
|
this.logger.debug(`Resolving Fn::ImportValue: ${exportName}`);
|
|
5444
5697
|
const allStacks = await context.stateBackend.listStacks();
|
|
5445
|
-
this.logger.debug(
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5698
|
+
this.logger.debug(
|
|
5699
|
+
`Found ${allStacks.length} state record(s) to search for export: ${exportName}`
|
|
5700
|
+
);
|
|
5701
|
+
for (const ref of allStacks) {
|
|
5702
|
+
const { stackName: refStack, region: refRegion } = ref;
|
|
5703
|
+
if (context.stackName && refStack === context.stackName) {
|
|
5704
|
+
this.logger.debug(`Skipping current stack: ${refStack}`);
|
|
5449
5705
|
continue;
|
|
5450
5706
|
}
|
|
5451
5707
|
try {
|
|
5452
|
-
const
|
|
5708
|
+
const lookupRegion = refRegion ?? this.resolverRegion ?? "";
|
|
5709
|
+
if (!lookupRegion) {
|
|
5710
|
+
this.logger.debug(
|
|
5711
|
+
`No region available for stack '${refStack}' \u2014 skipping (cdkd cannot read state without a region)`
|
|
5712
|
+
);
|
|
5713
|
+
continue;
|
|
5714
|
+
}
|
|
5715
|
+
const stateData = await context.stateBackend.getState(refStack, lookupRegion);
|
|
5453
5716
|
if (!stateData) {
|
|
5454
|
-
this.logger.debug(`No state found for stack: ${
|
|
5717
|
+
this.logger.debug(`No state found for stack: ${refStack} (${lookupRegion})`);
|
|
5455
5718
|
continue;
|
|
5456
5719
|
}
|
|
5457
5720
|
const { state } = stateData;
|
|
5458
5721
|
if (state.outputs && exportName in state.outputs) {
|
|
5459
5722
|
const value = state.outputs[exportName];
|
|
5460
5723
|
this.logger.info(
|
|
5461
|
-
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${
|
|
5724
|
+
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${refStack} / ${lookupRegion})`
|
|
5462
5725
|
);
|
|
5463
5726
|
return value;
|
|
5464
5727
|
}
|
|
5465
5728
|
} catch (error) {
|
|
5466
5729
|
this.logger.warn(
|
|
5467
|
-
`Failed to read state for stack ${
|
|
5730
|
+
`Failed to read state for stack ${refStack}: ${error instanceof Error ? error.message : String(error)}`
|
|
5468
5731
|
);
|
|
5469
5732
|
continue;
|
|
5470
5733
|
}
|
|
5471
5734
|
}
|
|
5472
5735
|
throw new Error(
|
|
5473
|
-
`Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length}
|
|
5736
|
+
`Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length} state record(s). Make sure the exporting stack has been deployed and the Output has an Export.Name property.`
|
|
5474
5737
|
);
|
|
5475
5738
|
}
|
|
5476
5739
|
/**
|
|
@@ -26251,7 +26514,11 @@ var DeployEngine = class {
|
|
|
26251
26514
|
logger = getLogger().child("DeployEngine");
|
|
26252
26515
|
resolver;
|
|
26253
26516
|
interrupted = false;
|
|
26254
|
-
/**
|
|
26517
|
+
/**
|
|
26518
|
+
* Target region for this stack. Required — load-bearing for the
|
|
26519
|
+
* region-prefixed S3 state key and recorded in state.json for
|
|
26520
|
+
* cross-region destroy.
|
|
26521
|
+
*/
|
|
26255
26522
|
stackRegion;
|
|
26256
26523
|
/**
|
|
26257
26524
|
* Deploy a CloudFormation template
|
|
@@ -26260,7 +26527,7 @@ var DeployEngine = class {
|
|
|
26260
26527
|
const startTime = Date.now();
|
|
26261
26528
|
this.logger.debug(`Starting deployment for stack: ${stackName}`);
|
|
26262
26529
|
setCurrentStackName(stackName);
|
|
26263
|
-
await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
|
|
26530
|
+
await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
|
|
26264
26531
|
const renderer = getLiveRenderer();
|
|
26265
26532
|
renderer.start();
|
|
26266
26533
|
this.interrupted = false;
|
|
@@ -26274,16 +26541,17 @@ var DeployEngine = class {
|
|
|
26274
26541
|
};
|
|
26275
26542
|
process.on("SIGINT", sigintHandler);
|
|
26276
26543
|
try {
|
|
26277
|
-
const currentStateData = await this.stateBackend.getState(stackName);
|
|
26544
|
+
const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
26278
26545
|
const currentState = currentStateData?.state ?? {
|
|
26279
|
-
version:
|
|
26280
|
-
|
|
26546
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26547
|
+
region: this.stackRegion,
|
|
26281
26548
|
stackName,
|
|
26282
26549
|
resources: {},
|
|
26283
26550
|
outputs: {},
|
|
26284
26551
|
lastModified: Date.now()
|
|
26285
26552
|
};
|
|
26286
26553
|
const currentEtag = currentStateData?.etag;
|
|
26554
|
+
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
26287
26555
|
this.logger.debug(
|
|
26288
26556
|
`Loaded current state: ${Object.keys(currentState.resources).length} resources`
|
|
26289
26557
|
);
|
|
@@ -26369,9 +26637,10 @@ var DeployEngine = class {
|
|
|
26369
26637
|
parameterValues,
|
|
26370
26638
|
conditions,
|
|
26371
26639
|
currentEtag,
|
|
26372
|
-
progress
|
|
26640
|
+
progress,
|
|
26641
|
+
migrationPending
|
|
26373
26642
|
);
|
|
26374
|
-
const newEtag = await this.stateBackend.saveState(stackName, newState);
|
|
26643
|
+
const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
|
|
26375
26644
|
this.logger.debug(`State saved (ETag: ${newEtag})`);
|
|
26376
26645
|
const durationMs = Date.now() - startTime;
|
|
26377
26646
|
const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
|
|
@@ -26387,7 +26656,7 @@ var DeployEngine = class {
|
|
|
26387
26656
|
renderer.stop();
|
|
26388
26657
|
process.removeListener("SIGINT", sigintHandler);
|
|
26389
26658
|
try {
|
|
26390
|
-
await this.lockManager.releaseLock(stackName);
|
|
26659
|
+
await this.lockManager.releaseLock(stackName, this.stackRegion);
|
|
26391
26660
|
this.logger.debug("Lock released");
|
|
26392
26661
|
} catch (lockError) {
|
|
26393
26662
|
this.logger.warn(
|
|
@@ -26405,11 +26674,12 @@ var DeployEngine = class {
|
|
|
26405
26674
|
* - DELETE follows reverse dependency order (a node starts as soon as all
|
|
26406
26675
|
* resources that depend ON it have finished deleting)
|
|
26407
26676
|
*/
|
|
26408
|
-
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
|
|
26677
|
+
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
|
|
26409
26678
|
const concurrency = this.options.concurrency;
|
|
26410
26679
|
const newResources = { ...currentState.resources };
|
|
26411
26680
|
const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
|
|
26412
26681
|
const completedOperations = [];
|
|
26682
|
+
let pendingMigration = migrationPending;
|
|
26413
26683
|
let saveChain = Promise.resolve();
|
|
26414
26684
|
const saveStateAfterResource = (logicalId) => {
|
|
26415
26685
|
if (currentEtag === void 0)
|
|
@@ -26417,14 +26687,23 @@ var DeployEngine = class {
|
|
|
26417
26687
|
saveChain = saveChain.then(async () => {
|
|
26418
26688
|
try {
|
|
26419
26689
|
const partialState = {
|
|
26420
|
-
version:
|
|
26421
|
-
|
|
26690
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26691
|
+
region: this.stackRegion,
|
|
26422
26692
|
stackName: currentState.stackName,
|
|
26423
26693
|
resources: newResources,
|
|
26424
26694
|
outputs: currentState.outputs,
|
|
26425
26695
|
lastModified: Date.now()
|
|
26426
26696
|
};
|
|
26427
|
-
|
|
26697
|
+
const migrate = pendingMigration;
|
|
26698
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
26699
|
+
currentEtag = await this.stateBackend.saveState(
|
|
26700
|
+
stackName,
|
|
26701
|
+
this.stackRegion,
|
|
26702
|
+
partialState,
|
|
26703
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
26704
|
+
);
|
|
26705
|
+
if (migrate)
|
|
26706
|
+
pendingMigration = false;
|
|
26428
26707
|
this.logger.debug(`State saved after ${logicalId}`);
|
|
26429
26708
|
} catch (error) {
|
|
26430
26709
|
this.logger.warn(
|
|
@@ -26558,14 +26837,23 @@ var DeployEngine = class {
|
|
|
26558
26837
|
} catch (error) {
|
|
26559
26838
|
try {
|
|
26560
26839
|
const preRollbackState = {
|
|
26561
|
-
version:
|
|
26562
|
-
|
|
26840
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26841
|
+
region: this.stackRegion,
|
|
26563
26842
|
stackName: currentState.stackName,
|
|
26564
26843
|
resources: newResources,
|
|
26565
26844
|
outputs: currentState.outputs,
|
|
26566
26845
|
lastModified: Date.now()
|
|
26567
26846
|
};
|
|
26568
|
-
|
|
26847
|
+
const migrate = pendingMigration;
|
|
26848
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
26849
|
+
currentEtag = await this.stateBackend.saveState(
|
|
26850
|
+
stackName,
|
|
26851
|
+
this.stackRegion,
|
|
26852
|
+
preRollbackState,
|
|
26853
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
26854
|
+
);
|
|
26855
|
+
if (migrate)
|
|
26856
|
+
pendingMigration = false;
|
|
26569
26857
|
this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
|
|
26570
26858
|
} catch (saveError) {
|
|
26571
26859
|
this.logger.warn(
|
|
@@ -26586,31 +26874,35 @@ var DeployEngine = class {
|
|
|
26586
26874
|
}
|
|
26587
26875
|
try {
|
|
26588
26876
|
const postRollbackState = {
|
|
26589
|
-
version:
|
|
26590
|
-
|
|
26877
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26878
|
+
region: this.stackRegion,
|
|
26591
26879
|
stackName: currentState.stackName,
|
|
26592
26880
|
resources: newResources,
|
|
26593
26881
|
outputs: currentState.outputs,
|
|
26594
26882
|
lastModified: Date.now()
|
|
26595
26883
|
};
|
|
26596
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
26884
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
26885
|
+
...currentEtag !== void 0 && { expectedEtag: currentEtag }
|
|
26886
|
+
});
|
|
26597
26887
|
this.logger.debug("State saved after deployment failure");
|
|
26598
26888
|
} catch (saveError) {
|
|
26599
26889
|
this.logger.debug(
|
|
26600
26890
|
`Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
|
|
26601
26891
|
);
|
|
26602
26892
|
try {
|
|
26603
|
-
const freshState = await this.stateBackend.getState(stackName);
|
|
26893
|
+
const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
26604
26894
|
const freshEtag = freshState?.etag;
|
|
26605
26895
|
const postRollbackState = {
|
|
26606
|
-
version:
|
|
26607
|
-
|
|
26896
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26897
|
+
region: this.stackRegion,
|
|
26608
26898
|
stackName: currentState.stackName,
|
|
26609
26899
|
resources: newResources,
|
|
26610
26900
|
outputs: currentState.outputs,
|
|
26611
26901
|
lastModified: Date.now()
|
|
26612
26902
|
};
|
|
26613
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
26903
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
26904
|
+
...freshEtag !== void 0 && { expectedEtag: freshEtag }
|
|
26905
|
+
});
|
|
26614
26906
|
this.logger.debug("State saved after deployment failure (retry succeeded)");
|
|
26615
26907
|
} catch (retryError) {
|
|
26616
26908
|
this.logger.warn(
|
|
@@ -26629,8 +26921,8 @@ var DeployEngine = class {
|
|
|
26629
26921
|
);
|
|
26630
26922
|
return {
|
|
26631
26923
|
state: {
|
|
26632
|
-
version:
|
|
26633
|
-
|
|
26924
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26925
|
+
region: this.stackRegion,
|
|
26634
26926
|
stackName: currentState.stackName,
|
|
26635
26927
|
resources: newResources,
|
|
26636
26928
|
outputs,
|
|
@@ -27637,19 +27929,18 @@ async function diffCommand(stacks, options) {
|
|
|
27637
27929
|
logger.info(`
|
|
27638
27930
|
Calculating diff for stack: ${stackInfo.stackName}`);
|
|
27639
27931
|
const template = stackInfo.template;
|
|
27640
|
-
|
|
27641
|
-
const stateResult = await stateBackend.getState(stackInfo.stackName);
|
|
27642
|
-
|
|
27643
|
-
|
|
27644
|
-
|
|
27645
|
-
|
|
27646
|
-
|
|
27647
|
-
|
|
27648
|
-
|
|
27649
|
-
|
|
27650
|
-
|
|
27651
|
-
|
|
27652
|
-
};
|
|
27932
|
+
const stackRegion = stackInfo.region || region;
|
|
27933
|
+
const stateResult = await stateBackend.getState(stackInfo.stackName, stackRegion);
|
|
27934
|
+
const currentState = stateResult ? stateResult.state : {
|
|
27935
|
+
stackName: stackInfo.stackName,
|
|
27936
|
+
region: stackRegion,
|
|
27937
|
+
resources: {},
|
|
27938
|
+
outputs: {},
|
|
27939
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
27940
|
+
lastModified: Date.now()
|
|
27941
|
+
};
|
|
27942
|
+
if (!stateResult) {
|
|
27943
|
+
logger.debug(`No existing state for ${stackInfo.stackName} (${stackRegion})`);
|
|
27653
27944
|
}
|
|
27654
27945
|
const diffResolveFn = (value) => intrinsicResolver.resolve(value, {
|
|
27655
27946
|
template,
|
|
@@ -27771,19 +28062,27 @@ async function destroyCommand(stackArgs, options) {
|
|
|
27771
28062
|
});
|
|
27772
28063
|
appStacks = result.stacks.map((s) => ({
|
|
27773
28064
|
stackName: s.stackName,
|
|
27774
|
-
displayName: s.displayName
|
|
28065
|
+
displayName: s.displayName,
|
|
28066
|
+
...s.region && { region: s.region }
|
|
27775
28067
|
}));
|
|
27776
28068
|
} catch {
|
|
27777
28069
|
logger.debug("Could not synthesize app, falling back to state-based stack list");
|
|
27778
28070
|
}
|
|
27779
28071
|
}
|
|
27780
|
-
const
|
|
28072
|
+
const allStateRefs = await stateBackend.listStacks();
|
|
27781
28073
|
let candidateStacks;
|
|
27782
28074
|
if (appStacks.length > 0) {
|
|
27783
|
-
const
|
|
27784
|
-
candidateStacks = appStacks.filter((s) =>
|
|
28075
|
+
const stateNames = new Set(allStateRefs.map((r) => r.stackName));
|
|
28076
|
+
candidateStacks = appStacks.filter((s) => stateNames.has(s.stackName));
|
|
27785
28077
|
} else if (stackArgs.length > 0 || options.stack || options.all) {
|
|
27786
|
-
|
|
28078
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28079
|
+
candidateStacks = [];
|
|
28080
|
+
for (const ref of allStateRefs) {
|
|
28081
|
+
if (seen.has(ref.stackName))
|
|
28082
|
+
continue;
|
|
28083
|
+
seen.add(ref.stackName);
|
|
28084
|
+
candidateStacks.push({ stackName: ref.stackName });
|
|
28085
|
+
}
|
|
27787
28086
|
} else {
|
|
27788
28087
|
throw new Error(
|
|
27789
28088
|
"Could not determine which stacks belong to this app. Specify stack names explicitly, use --all, or ensure --app / cdk.json is configured."
|
|
@@ -27810,10 +28109,38 @@ async function destroyCommand(stackArgs, options) {
|
|
|
27810
28109
|
return;
|
|
27811
28110
|
}
|
|
27812
28111
|
logger.info(`Found ${stackNames.length} stack(s) to destroy: ${stackNames.join(", ")}`);
|
|
28112
|
+
const stateRefsByName = /* @__PURE__ */ new Map();
|
|
28113
|
+
for (const ref of allStateRefs) {
|
|
28114
|
+
const arr = stateRefsByName.get(ref.stackName) ?? [];
|
|
28115
|
+
arr.push(ref);
|
|
28116
|
+
stateRefsByName.set(ref.stackName, arr);
|
|
28117
|
+
}
|
|
27813
28118
|
for (const stackName of stackNames) {
|
|
27814
28119
|
logger.info(`
|
|
27815
28120
|
Preparing to destroy stack: ${stackName}`);
|
|
27816
|
-
const
|
|
28121
|
+
const refs = stateRefsByName.get(stackName) ?? [];
|
|
28122
|
+
const synthStack = appStacks.find((s) => s.stackName === stackName);
|
|
28123
|
+
const synthRegion = synthStack?.region;
|
|
28124
|
+
let stackTargetRegion;
|
|
28125
|
+
if (refs.length === 0) {
|
|
28126
|
+
logger.warn(`No state found for stack ${stackName}, skipping`);
|
|
28127
|
+
continue;
|
|
28128
|
+
} else if (refs.length === 1) {
|
|
28129
|
+
const onlyRegion = refs[0]?.region;
|
|
28130
|
+
if (!onlyRegion) {
|
|
28131
|
+
stackTargetRegion = region;
|
|
28132
|
+
} else {
|
|
28133
|
+
stackTargetRegion = onlyRegion;
|
|
28134
|
+
}
|
|
28135
|
+
} else if (synthRegion && refs.some((r) => r.region === synthRegion)) {
|
|
28136
|
+
stackTargetRegion = synthRegion;
|
|
28137
|
+
} else {
|
|
28138
|
+
const regions = refs.map((r) => r.region ?? "(legacy)").join(", ");
|
|
28139
|
+
throw new Error(
|
|
28140
|
+
`Stack '${stackName}' has state in multiple regions: ${regions}. Use 'cdkd state rm ${stackName} --region <region>' to remove cdkd's record for one region, or run destroy from a CDK app whose env.region matches one of them.`
|
|
28141
|
+
);
|
|
28142
|
+
}
|
|
28143
|
+
const stateResult = await stateBackend.getState(stackName, stackTargetRegion);
|
|
27817
28144
|
if (!stateResult) {
|
|
27818
28145
|
logger.warn(`No state found for stack ${stackName}, skipping`);
|
|
27819
28146
|
continue;
|
|
@@ -27822,7 +28149,7 @@ Preparing to destroy stack: ${stackName}`);
|
|
|
27822
28149
|
const resourceCount = Object.keys(currentState.resources).length;
|
|
27823
28150
|
if (resourceCount === 0) {
|
|
27824
28151
|
logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
|
|
27825
|
-
await stateBackend.deleteState(stackName);
|
|
28152
|
+
await stateBackend.deleteState(stackName, stackTargetRegion);
|
|
27826
28153
|
logger.info("\u2713 State deleted");
|
|
27827
28154
|
continue;
|
|
27828
28155
|
}
|
|
@@ -27847,7 +28174,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
|
|
|
27847
28174
|
continue;
|
|
27848
28175
|
}
|
|
27849
28176
|
}
|
|
27850
|
-
const stackRegion =
|
|
28177
|
+
const stackRegion = stackTargetRegion;
|
|
27851
28178
|
let destroyProviderRegistry = providerRegistry;
|
|
27852
28179
|
let destroyAwsClients;
|
|
27853
28180
|
if (stackRegion && stackRegion !== region) {
|
|
@@ -27865,7 +28192,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
|
|
|
27865
28192
|
}
|
|
27866
28193
|
logger.info(`
|
|
27867
28194
|
Acquiring lock for stack ${stackName}...`);
|
|
27868
|
-
await lockManager.acquireLock(stackName, "destroy");
|
|
28195
|
+
await lockManager.acquireLock(stackName, stackRegion, void 0, "destroy");
|
|
27869
28196
|
const renderer = getLiveRenderer();
|
|
27870
28197
|
renderer.start();
|
|
27871
28198
|
try {
|
|
@@ -27980,7 +28307,7 @@ Acquiring lock for stack ${stackName}...`);
|
|
|
27980
28307
|
await Promise.all(deletePromises);
|
|
27981
28308
|
}
|
|
27982
28309
|
if (errorCount === 0) {
|
|
27983
|
-
await stateBackend.deleteState(stackName);
|
|
28310
|
+
await stateBackend.deleteState(stackName, stackRegion);
|
|
27984
28311
|
logger.debug("State deleted");
|
|
27985
28312
|
} else {
|
|
27986
28313
|
logger.warn(`${errorCount} resource(s) failed to delete. State preserved.`);
|
|
@@ -27992,7 +28319,7 @@ Acquiring lock for stack ${stackName}...`);
|
|
|
27992
28319
|
} finally {
|
|
27993
28320
|
renderer.stop();
|
|
27994
28321
|
logger.debug("Releasing lock...");
|
|
27995
|
-
await lockManager.releaseLock(stackName);
|
|
28322
|
+
await lockManager.releaseLock(stackName, stackRegion);
|
|
27996
28323
|
if (destroyAwsClients) {
|
|
27997
28324
|
destroyAwsClients.destroy();
|
|
27998
28325
|
process.env["AWS_REGION"] = region;
|
|
@@ -28053,7 +28380,7 @@ function createPublishAssetsCommand() {
|
|
|
28053
28380
|
}
|
|
28054
28381
|
|
|
28055
28382
|
// src/cli/commands/force-unlock.ts
|
|
28056
|
-
import { Command as Command8 } from "commander";
|
|
28383
|
+
import { Command as Command8, Option as Option3 } from "commander";
|
|
28057
28384
|
init_aws_clients();
|
|
28058
28385
|
async function forceUnlockCommand(stackArgs, options) {
|
|
28059
28386
|
const logger = getLogger();
|
|
@@ -28077,17 +28404,33 @@ async function forceUnlockCommand(stackArgs, options) {
|
|
|
28077
28404
|
prefix: options.statePrefix
|
|
28078
28405
|
};
|
|
28079
28406
|
const lockManager = new LockManager(awsClients.s3, stateConfig);
|
|
28407
|
+
const stateBackend = new S3StateBackend(awsClients.s3, stateConfig);
|
|
28080
28408
|
for (const stackName of stackPatterns) {
|
|
28081
|
-
|
|
28082
|
-
|
|
28083
|
-
|
|
28084
|
-
|
|
28085
|
-
|
|
28086
|
-
const
|
|
28087
|
-
if (
|
|
28088
|
-
|
|
28409
|
+
let regionsToTry;
|
|
28410
|
+
if (options.stackRegion) {
|
|
28411
|
+
regionsToTry = [options.stackRegion];
|
|
28412
|
+
} else {
|
|
28413
|
+
const refs = await stateBackend.listStacks();
|
|
28414
|
+
const matched = refs.filter((r) => r.stackName === stackName);
|
|
28415
|
+
if (matched.length === 0) {
|
|
28416
|
+
regionsToTry = [region];
|
|
28089
28417
|
} else {
|
|
28090
|
-
|
|
28418
|
+
regionsToTry = matched.map((r) => r.region);
|
|
28419
|
+
}
|
|
28420
|
+
}
|
|
28421
|
+
for (const r of regionsToTry) {
|
|
28422
|
+
const where = r ? `${stackName} (${r})` : `${stackName} (legacy lock key)`;
|
|
28423
|
+
logger.info(`Force-unlocking stack: ${where}`);
|
|
28424
|
+
try {
|
|
28425
|
+
await lockManager.forceReleaseLock(stackName, r);
|
|
28426
|
+
logger.info(`\u2713 Lock released for stack: ${where}`);
|
|
28427
|
+
} catch (error) {
|
|
28428
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28429
|
+
if (message.includes("No lock found") || message.includes("NoSuchKey")) {
|
|
28430
|
+
logger.info(`No lock found for stack: ${where}`);
|
|
28431
|
+
} else {
|
|
28432
|
+
logger.error(`Failed to unlock stack ${where}: ${message}`);
|
|
28433
|
+
}
|
|
28091
28434
|
}
|
|
28092
28435
|
}
|
|
28093
28436
|
}
|
|
@@ -28096,15 +28439,47 @@ async function forceUnlockCommand(stackArgs, options) {
|
|
|
28096
28439
|
}
|
|
28097
28440
|
}
|
|
28098
28441
|
function createForceUnlockCommand() {
|
|
28099
|
-
const cmd = new Command8("force-unlock").description("Force-release a stale lock on a stack").argument("[stacks...]", "Stack name(s) to unlock").
|
|
28442
|
+
const cmd = new Command8("force-unlock").description("Force-release a stale lock on a stack").argument("[stacks...]", "Stack name(s) to unlock").addOption(
|
|
28443
|
+
new Option3(
|
|
28444
|
+
"--stack-region <region>",
|
|
28445
|
+
"Stack region whose lock to release (use when the same stack name has locks in multiple regions). Defaults to all regions where the stack has state."
|
|
28446
|
+
)
|
|
28447
|
+
).action(withErrorHandling(forceUnlockCommand));
|
|
28100
28448
|
[...commonOptions, ...stateOptions, ...stackOptions].forEach((opt) => cmd.addOption(opt));
|
|
28101
28449
|
return cmd;
|
|
28102
28450
|
}
|
|
28103
28451
|
|
|
28104
28452
|
// src/cli/commands/state.ts
|
|
28105
28453
|
import * as readline2 from "node:readline/promises";
|
|
28106
|
-
import { Command as Command9 } from "commander";
|
|
28454
|
+
import { Command as Command9, Option as Option4 } from "commander";
|
|
28107
28455
|
init_aws_clients();
|
|
28456
|
+
function formatStackRef(ref) {
|
|
28457
|
+
return ref.region ? `${ref.stackName} (${ref.region})` : ref.stackName;
|
|
28458
|
+
}
|
|
28459
|
+
function resolveSingleRegion(stackName, refs, requestedRegion) {
|
|
28460
|
+
const matches = refs.filter((r) => r.stackName === stackName);
|
|
28461
|
+
if (matches.length === 0) {
|
|
28462
|
+
throw new Error(
|
|
28463
|
+
`No state found for stack '${stackName}'. Run 'cdkd state list' to see available stacks.`
|
|
28464
|
+
);
|
|
28465
|
+
}
|
|
28466
|
+
if (requestedRegion) {
|
|
28467
|
+
const ref = matches.find((r) => r.region === requestedRegion);
|
|
28468
|
+
if (!ref) {
|
|
28469
|
+
const seen = matches.map((r) => r.region ?? "(legacy)").join(", ");
|
|
28470
|
+
throw new Error(
|
|
28471
|
+
`No state found for stack '${stackName}' in region '${requestedRegion}'. Available regions: ${seen}.`
|
|
28472
|
+
);
|
|
28473
|
+
}
|
|
28474
|
+
return ref;
|
|
28475
|
+
}
|
|
28476
|
+
if (matches.length === 1)
|
|
28477
|
+
return matches[0];
|
|
28478
|
+
const regions = matches.map((r) => r.region ?? "(legacy)").join(", ");
|
|
28479
|
+
throw new Error(
|
|
28480
|
+
`Stack '${stackName}' has state in multiple regions: ${regions}. Re-run with --region <region> to disambiguate.`
|
|
28481
|
+
);
|
|
28482
|
+
}
|
|
28108
28483
|
async function setupStateBackend(options) {
|
|
28109
28484
|
const awsClients = new AwsClients({
|
|
28110
28485
|
...options.region && { region: options.region },
|
|
@@ -28126,34 +28501,52 @@ async function setupStateBackend(options) {
|
|
|
28126
28501
|
dispose: () => awsClients.destroy()
|
|
28127
28502
|
};
|
|
28128
28503
|
}
|
|
28504
|
+
function sortRefs(refs) {
|
|
28505
|
+
return refs.slice().sort((a, b) => {
|
|
28506
|
+
if (a.stackName < b.stackName)
|
|
28507
|
+
return -1;
|
|
28508
|
+
if (a.stackName > b.stackName)
|
|
28509
|
+
return 1;
|
|
28510
|
+
const ar = a.region ?? "\uFFFF";
|
|
28511
|
+
const br = b.region ?? "\uFFFF";
|
|
28512
|
+
if (ar < br)
|
|
28513
|
+
return -1;
|
|
28514
|
+
if (ar > br)
|
|
28515
|
+
return 1;
|
|
28516
|
+
return 0;
|
|
28517
|
+
});
|
|
28518
|
+
}
|
|
28129
28519
|
async function stateListCommand(options) {
|
|
28130
28520
|
const logger = getLogger();
|
|
28131
28521
|
if (options.verbose)
|
|
28132
28522
|
logger.setLevel("debug");
|
|
28133
28523
|
const setup = await setupStateBackend(options);
|
|
28134
28524
|
try {
|
|
28135
|
-
const
|
|
28525
|
+
const refs = sortRefs(await setup.stateBackend.listStacks());
|
|
28136
28526
|
if (!options.long && !options.json) {
|
|
28137
|
-
for (const
|
|
28138
|
-
process.stdout.write(`${
|
|
28527
|
+
for (const ref of refs) {
|
|
28528
|
+
process.stdout.write(`${formatStackRef(ref)}
|
|
28139
28529
|
`);
|
|
28140
28530
|
}
|
|
28141
28531
|
return;
|
|
28142
28532
|
}
|
|
28143
28533
|
if (options.json && !options.long) {
|
|
28144
|
-
|
|
28534
|
+
const payload = refs.map((r) => ({ stackName: r.stackName, region: r.region ?? null }));
|
|
28535
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
28145
28536
|
`);
|
|
28146
28537
|
return;
|
|
28147
28538
|
}
|
|
28148
28539
|
const details = await Promise.all(
|
|
28149
|
-
|
|
28540
|
+
refs.map(async (ref) => {
|
|
28541
|
+
const lookupRegion = ref.region ?? "";
|
|
28150
28542
|
const [stateResult, locked] = await Promise.all([
|
|
28151
|
-
setup.stateBackend.getState(stackName),
|
|
28152
|
-
setup.lockManager.isLocked(stackName)
|
|
28543
|
+
lookupRegion ? setup.stateBackend.getState(ref.stackName, lookupRegion) : Promise.resolve(null),
|
|
28544
|
+
setup.lockManager.isLocked(ref.stackName, ref.region)
|
|
28153
28545
|
]);
|
|
28154
28546
|
const state = stateResult?.state;
|
|
28155
28547
|
return {
|
|
28156
|
-
stackName,
|
|
28548
|
+
stackName: ref.stackName,
|
|
28549
|
+
region: ref.region ?? null,
|
|
28157
28550
|
resourceCount: state ? Object.keys(state.resources).length : 0,
|
|
28158
28551
|
lastModified: state && typeof state.lastModified === "number" ? new Date(state.lastModified).toISOString() : null,
|
|
28159
28552
|
locked
|
|
@@ -28167,7 +28560,13 @@ async function stateListCommand(options) {
|
|
|
28167
28560
|
}
|
|
28168
28561
|
const lines = [];
|
|
28169
28562
|
for (const detail of details) {
|
|
28170
|
-
lines.push(
|
|
28563
|
+
lines.push(
|
|
28564
|
+
formatStackRef({
|
|
28565
|
+
stackName: detail.stackName,
|
|
28566
|
+
...detail.region ? { region: detail.region } : {}
|
|
28567
|
+
})
|
|
28568
|
+
);
|
|
28569
|
+
lines.push(` Region: ${detail.region ?? "(legacy)"}`);
|
|
28171
28570
|
lines.push(` Resources: ${detail.resourceCount}`);
|
|
28172
28571
|
lines.push(` Last Modified: ${detail.lastModified ?? "unknown"}`);
|
|
28173
28572
|
lines.push(` Lock: ${detail.locked ? "locked" : "unlocked"}`);
|
|
@@ -28195,10 +28594,17 @@ async function stateResourcesCommand(stackName, options) {
|
|
|
28195
28594
|
logger.setLevel("debug");
|
|
28196
28595
|
const setup = await setupStateBackend(options);
|
|
28197
28596
|
try {
|
|
28198
|
-
const
|
|
28597
|
+
const refs = await setup.stateBackend.listStacks();
|
|
28598
|
+
const ref = resolveSingleRegion(stackName, refs, options.stackRegion);
|
|
28599
|
+
if (!ref.region) {
|
|
28600
|
+
throw new Error(
|
|
28601
|
+
`Stack '${stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${stackName}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`
|
|
28602
|
+
);
|
|
28603
|
+
}
|
|
28604
|
+
const stateResult = await setup.stateBackend.getState(stackName, ref.region);
|
|
28199
28605
|
if (!stateResult) {
|
|
28200
28606
|
throw new Error(
|
|
28201
|
-
`No state found for stack '${stackName}' in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
|
|
28607
|
+
`No state found for stack '${stackName}' (${ref.region}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
|
|
28202
28608
|
);
|
|
28203
28609
|
}
|
|
28204
28610
|
const resources = stateResult.state.resources ?? {};
|
|
@@ -28281,7 +28687,7 @@ function formatLockSummary(lockInfo) {
|
|
|
28281
28687
|
return `locked by ${lockInfo.owner}${opStr}, ${expiresStr}`;
|
|
28282
28688
|
}
|
|
28283
28689
|
function createStateResourcesCommand() {
|
|
28284
|
-
const cmd = new Command9("resources").description("List resources recorded in a stack's state").argument("<stack>", "Stack name (physical CloudFormation name)").option("-l, --long", "Include dependencies and attributes per resource", false).option("--json", "Output as JSON", false).action(withErrorHandling(stateResourcesCommand));
|
|
28690
|
+
const cmd = new Command9("resources").description("List resources recorded in a stack's state").argument("<stack>", "Stack name (physical CloudFormation name)").option("-l, --long", "Include dependencies and attributes per resource", false).option("--json", "Output as JSON", false).addOption(stackRegionOption()).action(withErrorHandling(stateResourcesCommand));
|
|
28285
28691
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28286
28692
|
return cmd;
|
|
28287
28693
|
}
|
|
@@ -28291,13 +28697,20 @@ async function stateShowCommand(stackName, options) {
|
|
|
28291
28697
|
logger.setLevel("debug");
|
|
28292
28698
|
const setup = await setupStateBackend(options);
|
|
28293
28699
|
try {
|
|
28700
|
+
const refs = await setup.stateBackend.listStacks();
|
|
28701
|
+
const ref = resolveSingleRegion(stackName, refs, options.stackRegion);
|
|
28702
|
+
if (!ref.region) {
|
|
28703
|
+
throw new Error(
|
|
28704
|
+
`Stack '${stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${stackName}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`
|
|
28705
|
+
);
|
|
28706
|
+
}
|
|
28294
28707
|
const [stateResult, lockInfo] = await Promise.all([
|
|
28295
|
-
setup.stateBackend.getState(stackName),
|
|
28296
|
-
setup.lockManager.getLockInfo(stackName)
|
|
28708
|
+
setup.stateBackend.getState(stackName, ref.region),
|
|
28709
|
+
setup.lockManager.getLockInfo(stackName, ref.region)
|
|
28297
28710
|
]);
|
|
28298
28711
|
if (!stateResult) {
|
|
28299
28712
|
throw new Error(
|
|
28300
|
-
`No state found for stack '${stackName}' in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
|
|
28713
|
+
`No state found for stack '${stackName}' (${ref.region}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
|
|
28301
28714
|
);
|
|
28302
28715
|
}
|
|
28303
28716
|
if (options.json) {
|
|
@@ -28361,7 +28774,7 @@ async function stateShowCommand(stackName, options) {
|
|
|
28361
28774
|
}
|
|
28362
28775
|
}
|
|
28363
28776
|
function createStateShowCommand() {
|
|
28364
|
-
const cmd = new Command9("show").description("Show the full cdkd state record for a stack (metadata, outputs, resources)").argument("<stack>", "Stack name (physical CloudFormation name)").option("--json", "Output the raw state and lock as JSON", false).action(withErrorHandling(stateShowCommand));
|
|
28777
|
+
const cmd = new Command9("show").description("Show the full cdkd state record for a stack (metadata, outputs, resources)").argument("<stack>", "Stack name (physical CloudFormation name)").option("--json", "Output the raw state and lock as JSON", false).addOption(stackRegionOption()).action(withErrorHandling(stateShowCommand));
|
|
28365
28778
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28366
28779
|
return cmd;
|
|
28367
28780
|
}
|
|
@@ -28374,24 +28787,36 @@ async function stateRmCommand(stackArgs, options) {
|
|
|
28374
28787
|
}
|
|
28375
28788
|
const setup = await setupStateBackend(options);
|
|
28376
28789
|
try {
|
|
28790
|
+
const refs = await setup.stateBackend.listStacks();
|
|
28377
28791
|
for (const stackName of stackArgs) {
|
|
28378
|
-
const
|
|
28379
|
-
if (
|
|
28792
|
+
const stackRefs = refs.filter((r) => r.stackName === stackName);
|
|
28793
|
+
if (stackRefs.length === 0) {
|
|
28380
28794
|
logger.info(`No state found for stack: ${stackName}, skipping`);
|
|
28381
28795
|
continue;
|
|
28382
28796
|
}
|
|
28797
|
+
const targets = options.stackRegion ? stackRefs.filter((r) => r.region === options.stackRegion) : stackRefs;
|
|
28798
|
+
if (targets.length === 0) {
|
|
28799
|
+
const seen = stackRefs.map((r) => r.region ?? "(legacy)").join(", ");
|
|
28800
|
+
throw new Error(
|
|
28801
|
+
`No state found for stack '${stackName}' in region '${options.stackRegion}'. Available regions: ${seen}.`
|
|
28802
|
+
);
|
|
28803
|
+
}
|
|
28383
28804
|
if (!options.force) {
|
|
28384
|
-
const
|
|
28385
|
-
|
|
28386
|
-
|
|
28387
|
-
|
|
28388
|
-
|
|
28805
|
+
for (const target of targets) {
|
|
28806
|
+
const locked = await setup.lockManager.isLocked(stackName, target.region);
|
|
28807
|
+
if (locked) {
|
|
28808
|
+
const where = target.region ?? "(legacy)";
|
|
28809
|
+
throw new Error(
|
|
28810
|
+
`Stack '${stackName}' (${where}) is locked. Run 'cdkd force-unlock ${stackName}${target.region ? ` --stack-region ${target.region}` : ""}' first, or pass --force to remove anyway.`
|
|
28811
|
+
);
|
|
28812
|
+
}
|
|
28389
28813
|
}
|
|
28390
28814
|
}
|
|
28391
28815
|
if (!options.yes && !options.force) {
|
|
28816
|
+
const targetList = targets.map((t) => formatStackRef(t)).join(", ");
|
|
28392
28817
|
process.stdout.write(
|
|
28393
28818
|
`
|
|
28394
|
-
WARNING: This removes cdkd's state record for
|
|
28819
|
+
WARNING: This removes cdkd's state record for [${targetList}] only. AWS resources will NOT be deleted.
|
|
28395
28820
|
Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
28396
28821
|
|
|
28397
28822
|
`
|
|
@@ -28401,7 +28826,7 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
|
28401
28826
|
output: process.stdout
|
|
28402
28827
|
});
|
|
28403
28828
|
const answer = await rl.question(
|
|
28404
|
-
`Remove state for
|
|
28829
|
+
`Remove state for ${targetList} from s3://${setup.bucket}/${setup.prefix}/? (y/N): `
|
|
28405
28830
|
);
|
|
28406
28831
|
rl.close();
|
|
28407
28832
|
const trimmed = answer.trim().toLowerCase();
|
|
@@ -28410,16 +28835,28 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
|
28410
28835
|
continue;
|
|
28411
28836
|
}
|
|
28412
28837
|
}
|
|
28413
|
-
|
|
28414
|
-
|
|
28415
|
-
|
|
28838
|
+
for (const target of targets) {
|
|
28839
|
+
if (target.region) {
|
|
28840
|
+
await setup.stateBackend.deleteState(stackName, target.region);
|
|
28841
|
+
await setup.lockManager.forceReleaseLock(stackName, target.region);
|
|
28842
|
+
} else {
|
|
28843
|
+
await setup.lockManager.forceReleaseLock(stackName, void 0);
|
|
28844
|
+
}
|
|
28845
|
+
logger.info(`\u2713 Removed state for stack: ${formatStackRef(target)}`);
|
|
28846
|
+
}
|
|
28416
28847
|
}
|
|
28417
28848
|
} finally {
|
|
28418
28849
|
setup.dispose();
|
|
28419
28850
|
}
|
|
28420
28851
|
}
|
|
28852
|
+
function stackRegionOption() {
|
|
28853
|
+
return new Option4(
|
|
28854
|
+
"--stack-region <region>",
|
|
28855
|
+
"Region of the stack record to operate on. Required when the same stack name has state in multiple regions."
|
|
28856
|
+
);
|
|
28857
|
+
}
|
|
28421
28858
|
function createStateRmCommand() {
|
|
28422
|
-
const cmd = new Command9("rm").description("Remove cdkd state for one or more stacks (does NOT delete AWS resources)").argument("<stacks...>", "Stack name(s) to remove from state").option("-f, --force", "Skip confirmation and remove even if the stack is locked", false).action(withErrorHandling(stateRmCommand));
|
|
28859
|
+
const cmd = new Command9("rm").description("Remove cdkd state for one or more stacks (does NOT delete AWS resources)").argument("<stacks...>", "Stack name(s) to remove from state").option("-f, --force", "Skip confirmation and remove even if the stack is locked", false).addOption(stackRegionOption()).action(withErrorHandling(stateRmCommand));
|
|
28423
28860
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28424
28861
|
return cmd;
|
|
28425
28862
|
}
|
|
@@ -28457,7 +28894,7 @@ function reorderArgs(argv) {
|
|
|
28457
28894
|
}
|
|
28458
28895
|
async function main() {
|
|
28459
28896
|
const program = new Command10();
|
|
28460
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
28897
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.8.0");
|
|
28461
28898
|
program.addCommand(createBootstrapCommand());
|
|
28462
28899
|
program.addCommand(createSynthCommand());
|
|
28463
28900
|
program.addCommand(createListCommand());
|