@go-to-k/cdkd 0.7.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 +650 -218
- 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.7.0.tgz +0 -0
package/dist/cli.js
CHANGED
|
@@ -3205,6 +3205,14 @@ import {
|
|
|
3205
3205
|
ListObjectsV2Command,
|
|
3206
3206
|
NoSuchKey
|
|
3207
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;
|
|
3208
3216
|
var S3StateBackend = class {
|
|
3209
3217
|
constructor(s3Client, config) {
|
|
3210
3218
|
this.s3Client = s3Client;
|
|
@@ -3212,9 +3220,16 @@ var S3StateBackend = class {
|
|
|
3212
3220
|
}
|
|
3213
3221
|
logger = getLogger().child("S3StateBackend");
|
|
3214
3222
|
/**
|
|
3215
|
-
* Get the S3 key for a stack's state file
|
|
3223
|
+
* Get the new (region-scoped) S3 key for a stack's state file.
|
|
3224
|
+
*/
|
|
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.
|
|
3216
3231
|
*/
|
|
3217
|
-
|
|
3232
|
+
getLegacyStateKey(stackName) {
|
|
3218
3233
|
return `${this.config.prefix}/${stackName}/state.json`;
|
|
3219
3234
|
}
|
|
3220
3235
|
/**
|
|
@@ -3240,101 +3255,143 @@ var S3StateBackend = class {
|
|
|
3240
3255
|
}
|
|
3241
3256
|
}
|
|
3242
3257
|
/**
|
|
3243
|
-
* Check if state exists for a stack
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
);
|
|
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)) {
|
|
3254
3268
|
return true;
|
|
3255
|
-
} catch (error) {
|
|
3256
|
-
if (error instanceof NoSuchKey || error.name === "NotFound") {
|
|
3257
|
-
return false;
|
|
3258
|
-
}
|
|
3259
|
-
throw new StateError(
|
|
3260
|
-
`Failed to check if state exists for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3261
|
-
error instanceof Error ? error : void 0
|
|
3262
|
-
);
|
|
3263
3269
|
}
|
|
3270
|
+
return this.legacyMatchesRegion(stackName, region);
|
|
3264
3271
|
}
|
|
3265
3272
|
/**
|
|
3266
|
-
* Get state for a stack
|
|
3273
|
+
* Get state for a stack, transparently falling back to the legacy key.
|
|
3274
|
+
*
|
|
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.
|
|
3267
3279
|
*
|
|
3268
|
-
*
|
|
3269
|
-
*
|
|
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.
|
|
3270
3286
|
*/
|
|
3271
|
-
async getState(stackName) {
|
|
3272
|
-
const
|
|
3287
|
+
async getState(stackName, region) {
|
|
3288
|
+
const newKey = this.getStateKey(stackName, region);
|
|
3273
3289
|
try {
|
|
3274
|
-
this.logger.debug(`Getting state for stack: ${stackName}`);
|
|
3290
|
+
this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
|
|
3275
3291
|
const response = await this.s3Client.send(
|
|
3276
3292
|
new GetObjectCommand({
|
|
3277
3293
|
Bucket: this.config.bucket,
|
|
3278
|
-
Key:
|
|
3294
|
+
Key: newKey
|
|
3279
3295
|
})
|
|
3280
3296
|
);
|
|
3281
3297
|
if (!response.Body) {
|
|
3282
|
-
throw new StateError(`State file for stack '${stackName}' has no body`);
|
|
3298
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no body`);
|
|
3283
3299
|
}
|
|
3284
3300
|
if (!response.ETag) {
|
|
3285
|
-
throw new StateError(`State file for stack '${stackName}' has no ETag`);
|
|
3301
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no ETag`);
|
|
3286
3302
|
}
|
|
3287
3303
|
const bodyString = await response.Body.transformToString();
|
|
3288
|
-
const state =
|
|
3289
|
-
this.logger.debug(`Retrieved state
|
|
3290
|
-
return {
|
|
3291
|
-
state,
|
|
3292
|
-
etag: response.ETag
|
|
3293
|
-
};
|
|
3304
|
+
const state = this.parseStateBody(bodyString, stackName);
|
|
3305
|
+
this.logger.debug(`Retrieved state: ${stackName} (${region}), ETag: ${response.ETag}`);
|
|
3306
|
+
return { state, etag: response.ETag };
|
|
3294
3307
|
} catch (error) {
|
|
3295
|
-
if (error
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
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
|
+
);
|
|
3301
3315
|
}
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
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.`
|
|
3305
3322
|
);
|
|
3323
|
+
return { ...legacy, migrationPending: true };
|
|
3306
3324
|
}
|
|
3325
|
+
return null;
|
|
3307
3326
|
}
|
|
3308
3327
|
/**
|
|
3309
|
-
* 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.
|
|
3310
3339
|
*
|
|
3311
3340
|
* @param stackName Stack name
|
|
3341
|
+
* @param region Target region (load-bearing — part of the S3 key)
|
|
3312
3342
|
* @param state State to save
|
|
3313
|
-
* @param
|
|
3314
|
-
*
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
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
|
+
};
|
|
3319
3355
|
try {
|
|
3320
3356
|
this.logger.debug(
|
|
3321
|
-
`Saving state
|
|
3357
|
+
`Saving state: ${stackName} (${region})${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
|
|
3322
3358
|
);
|
|
3323
|
-
const
|
|
3359
|
+
const bodyString = JSON.stringify(body, null, 2);
|
|
3324
3360
|
const response = await this.s3Client.send(
|
|
3325
3361
|
new PutObjectCommand2({
|
|
3326
3362
|
Bucket: this.config.bucket,
|
|
3327
|
-
Key:
|
|
3328
|
-
Body:
|
|
3329
|
-
ContentLength: Buffer.byteLength(
|
|
3363
|
+
Key: newKey,
|
|
3364
|
+
Body: bodyString,
|
|
3365
|
+
ContentLength: Buffer.byteLength(bodyString),
|
|
3330
3366
|
ContentType: "application/json",
|
|
3331
|
-
|
|
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 }
|
|
3332
3370
|
})
|
|
3333
3371
|
);
|
|
3334
3372
|
if (!response.ETag) {
|
|
3335
|
-
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
|
+
}
|
|
3336
3394
|
}
|
|
3337
|
-
this.logger.debug(`State saved for stack: ${stackName}, new ETag: ${response.ETag}`);
|
|
3338
3395
|
return response.ETag;
|
|
3339
3396
|
} catch (error) {
|
|
3340
3397
|
if (error.name === "PreconditionFailed") {
|
|
@@ -3343,63 +3400,230 @@ var S3StateBackend = class {
|
|
|
3343
3400
|
);
|
|
3344
3401
|
}
|
|
3345
3402
|
throw new StateError(
|
|
3346
|
-
`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)}`,
|
|
3347
3404
|
error instanceof Error ? error : void 0
|
|
3348
3405
|
);
|
|
3349
3406
|
}
|
|
3350
3407
|
}
|
|
3351
3408
|
/**
|
|
3352
|
-
* 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.
|
|
3353
3414
|
*/
|
|
3354
|
-
async deleteState(stackName) {
|
|
3355
|
-
const key = this.getStateKey(stackName);
|
|
3415
|
+
async deleteState(stackName, region) {
|
|
3356
3416
|
try {
|
|
3357
|
-
this.logger.debug(`Deleting state
|
|
3417
|
+
this.logger.debug(`Deleting state: ${stackName} (${region})`);
|
|
3358
3418
|
await this.s3Client.send(
|
|
3359
3419
|
new DeleteObjectCommand({
|
|
3360
3420
|
Bucket: this.config.bucket,
|
|
3361
|
-
Key:
|
|
3421
|
+
Key: this.getStateKey(stackName, region)
|
|
3362
3422
|
})
|
|
3363
3423
|
);
|
|
3364
|
-
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})`);
|
|
3365
3434
|
} catch (error) {
|
|
3366
3435
|
throw new StateError(
|
|
3367
|
-
`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)}`,
|
|
3368
3437
|
error instanceof Error ? error : void 0
|
|
3369
3438
|
);
|
|
3370
3439
|
}
|
|
3371
3440
|
}
|
|
3372
3441
|
/**
|
|
3373
|
-
* 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.
|
|
3374
3454
|
*/
|
|
3375
3455
|
async listStacks() {
|
|
3376
3456
|
try {
|
|
3377
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 {
|
|
3539
|
+
const response = await this.s3Client.send(
|
|
3540
|
+
new GetObjectCommand({
|
|
3541
|
+
Bucket: this.config.bucket,
|
|
3542
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3543
|
+
})
|
|
3544
|
+
);
|
|
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 {
|
|
3378
3569
|
const response = await this.s3Client.send(
|
|
3379
|
-
new
|
|
3570
|
+
new GetObjectCommand({
|
|
3380
3571
|
Bucket: this.config.bucket,
|
|
3381
|
-
|
|
3382
|
-
Delimiter: "/"
|
|
3572
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3383
3573
|
})
|
|
3384
3574
|
);
|
|
3385
|
-
if (!response.
|
|
3386
|
-
return
|
|
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;
|
|
3387
3585
|
}
|
|
3388
|
-
|
|
3389
|
-
const prefixStr = prefix.Prefix || "";
|
|
3390
|
-
const parts = prefixStr.split("/");
|
|
3391
|
-
return parts[parts.length - 2];
|
|
3392
|
-
}).filter((name) => Boolean(name));
|
|
3393
|
-
this.logger.debug(`Found ${stackNames.length} stacks`);
|
|
3394
|
-
return stackNames;
|
|
3586
|
+
return { state, etag: response.ETag };
|
|
3395
3587
|
} catch (error) {
|
|
3588
|
+
if (isNoSuchKey(error))
|
|
3589
|
+
return null;
|
|
3396
3590
|
throw new StateError(
|
|
3397
|
-
`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)}`,
|
|
3398
3609
|
error instanceof Error ? error : void 0
|
|
3399
3610
|
);
|
|
3400
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;
|
|
3401
3619
|
}
|
|
3402
3620
|
};
|
|
3621
|
+
function isNoSuchKey(error) {
|
|
3622
|
+
if (error instanceof NoSuchKey)
|
|
3623
|
+
return true;
|
|
3624
|
+
const name = error?.name;
|
|
3625
|
+
return name === "NoSuchKey";
|
|
3626
|
+
}
|
|
3403
3627
|
|
|
3404
3628
|
// src/state/lock-manager.ts
|
|
3405
3629
|
import {
|
|
@@ -3420,10 +3644,24 @@ var LockManager = class {
|
|
|
3420
3644
|
logger = getLogger().child("LockManager");
|
|
3421
3645
|
ttlMs;
|
|
3422
3646
|
/**
|
|
3423
|
-
* 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.
|
|
3424
3659
|
*/
|
|
3425
|
-
getLockKey(stackName) {
|
|
3426
|
-
|
|
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`;
|
|
3427
3665
|
}
|
|
3428
3666
|
/**
|
|
3429
3667
|
* Get default lock owner identifier
|
|
@@ -3462,11 +3700,12 @@ var LockManager = class {
|
|
|
3462
3700
|
* If an expired lock exists, it will be cleaned up and re-acquired.
|
|
3463
3701
|
*
|
|
3464
3702
|
* @param stackName Stack name
|
|
3703
|
+
* @param region Target region (lock key is region-scoped)
|
|
3465
3704
|
* @param owner Lock owner identifier (defaults to user@hostname:pid)
|
|
3466
3705
|
* @param operation Operation being performed (e.g., "deploy", "destroy")
|
|
3467
3706
|
*/
|
|
3468
|
-
async acquireLock(stackName, owner, operation) {
|
|
3469
|
-
const key = this.getLockKey(stackName);
|
|
3707
|
+
async acquireLock(stackName, region, owner, operation) {
|
|
3708
|
+
const key = this.getLockKey(stackName, region);
|
|
3470
3709
|
const lockOwner = owner || this.getDefaultOwner();
|
|
3471
3710
|
const now = Date.now();
|
|
3472
3711
|
const lockInfo = {
|
|
@@ -3476,7 +3715,7 @@ var LockManager = class {
|
|
|
3476
3715
|
...operation && { operation }
|
|
3477
3716
|
};
|
|
3478
3717
|
try {
|
|
3479
|
-
this.logger.debug(`Attempting to acquire lock for stack: ${stackName}`);
|
|
3718
|
+
this.logger.debug(`Attempting to acquire lock for stack: ${stackName} (${region})`);
|
|
3480
3719
|
const lockBody = JSON.stringify(lockInfo, null, 2);
|
|
3481
3720
|
await this.s3Client.send(
|
|
3482
3721
|
new PutObjectCommand3({
|
|
@@ -3489,17 +3728,17 @@ var LockManager = class {
|
|
|
3489
3728
|
// Only succeed if object doesn't exist
|
|
3490
3729
|
})
|
|
3491
3730
|
);
|
|
3492
|
-
this.logger.debug(`Lock acquired for stack: ${stackName}, owner: ${lockOwner}`);
|
|
3731
|
+
this.logger.debug(`Lock acquired for stack: ${stackName} (${region}), owner: ${lockOwner}`);
|
|
3493
3732
|
return true;
|
|
3494
3733
|
} catch (error) {
|
|
3495
3734
|
if (error instanceof S3ServiceException && error.name === "PreconditionFailed") {
|
|
3496
|
-
this.logger.debug(`Lock already exists for stack: ${stackName}`);
|
|
3497
|
-
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);
|
|
3498
3737
|
if (existingLock && this.isLockExpired(existingLock)) {
|
|
3499
3738
|
this.logger.info(
|
|
3500
|
-
`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...`
|
|
3501
3740
|
);
|
|
3502
|
-
await this.deleteLock(stackName);
|
|
3741
|
+
await this.deleteLock(stackName, region);
|
|
3503
3742
|
try {
|
|
3504
3743
|
const retryBody = JSON.stringify(lockInfo, null, 2);
|
|
3505
3744
|
await this.s3Client.send(
|
|
@@ -3513,13 +3752,13 @@ var LockManager = class {
|
|
|
3513
3752
|
})
|
|
3514
3753
|
);
|
|
3515
3754
|
this.logger.debug(
|
|
3516
|
-
`Lock acquired for stack: ${stackName} after expired lock cleanup, owner: ${lockOwner}`
|
|
3755
|
+
`Lock acquired for stack: ${stackName} (${region}) after expired lock cleanup, owner: ${lockOwner}`
|
|
3517
3756
|
);
|
|
3518
3757
|
return true;
|
|
3519
3758
|
} catch (retryError) {
|
|
3520
3759
|
if (retryError instanceof S3ServiceException && retryError.name === "PreconditionFailed") {
|
|
3521
3760
|
this.logger.debug(
|
|
3522
|
-
`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})`
|
|
3523
3762
|
);
|
|
3524
3763
|
return false;
|
|
3525
3764
|
}
|
|
@@ -3529,16 +3768,20 @@ var LockManager = class {
|
|
|
3529
3768
|
return false;
|
|
3530
3769
|
}
|
|
3531
3770
|
throw new LockError(
|
|
3532
|
-
`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)}`,
|
|
3533
3772
|
error instanceof Error ? error : void 0
|
|
3534
3773
|
);
|
|
3535
3774
|
}
|
|
3536
3775
|
}
|
|
3537
3776
|
/**
|
|
3538
|
-
* 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).
|
|
3539
3782
|
*/
|
|
3540
|
-
async getLockInfo(stackName) {
|
|
3541
|
-
const key = this.getLockKey(stackName);
|
|
3783
|
+
async getLockInfo(stackName, region) {
|
|
3784
|
+
const key = this.getLockKey(stackName, region);
|
|
3542
3785
|
try {
|
|
3543
3786
|
this.logger.debug(`Getting lock info for stack: ${stackName}`);
|
|
3544
3787
|
const response = await this.s3Client.send(
|
|
@@ -3576,27 +3819,27 @@ var LockManager = class {
|
|
|
3576
3819
|
* not for acquisition decisions — use `acquireLock` for that, which has its
|
|
3577
3820
|
* own expired-lock cleanup logic.
|
|
3578
3821
|
*/
|
|
3579
|
-
async isLocked(stackName) {
|
|
3580
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3822
|
+
async isLocked(stackName, region) {
|
|
3823
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3581
3824
|
return lockInfo !== null;
|
|
3582
3825
|
}
|
|
3583
3826
|
/**
|
|
3584
3827
|
* Release a lock for a stack
|
|
3585
3828
|
*/
|
|
3586
|
-
async releaseLock(stackName) {
|
|
3587
|
-
const key = this.getLockKey(stackName);
|
|
3829
|
+
async releaseLock(stackName, region) {
|
|
3830
|
+
const key = this.getLockKey(stackName, region);
|
|
3588
3831
|
try {
|
|
3589
|
-
this.logger.debug(`Releasing lock for stack: ${stackName}`);
|
|
3832
|
+
this.logger.debug(`Releasing lock for stack: ${stackName} (${region})`);
|
|
3590
3833
|
await this.s3Client.send(
|
|
3591
3834
|
new DeleteObjectCommand2({
|
|
3592
3835
|
Bucket: this.config.bucket,
|
|
3593
3836
|
Key: key
|
|
3594
3837
|
})
|
|
3595
3838
|
);
|
|
3596
|
-
this.logger.debug(`Lock released for stack: ${stackName}`);
|
|
3839
|
+
this.logger.debug(`Lock released for stack: ${stackName} (${region})`);
|
|
3597
3840
|
} catch (error) {
|
|
3598
3841
|
throw new LockError(
|
|
3599
|
-
`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)}`,
|
|
3600
3843
|
error instanceof Error ? error : void 0
|
|
3601
3844
|
);
|
|
3602
3845
|
}
|
|
@@ -3606,23 +3849,28 @@ var LockManager = class {
|
|
|
3606
3849
|
*
|
|
3607
3850
|
* This is intended for CLI usage (e.g., --force-unlock flag) when a lock
|
|
3608
3851
|
* is stuck and needs manual intervention.
|
|
3852
|
+
*
|
|
3853
|
+
* Pass `region: undefined` to operate on a legacy
|
|
3854
|
+
* `{prefix}/{stackName}/lock.json` file.
|
|
3609
3855
|
*/
|
|
3610
|
-
async forceReleaseLock(stackName) {
|
|
3611
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3856
|
+
async forceReleaseLock(stackName, region) {
|
|
3857
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3612
3858
|
if (!lockInfo) {
|
|
3613
|
-
this.logger.warn(
|
|
3859
|
+
this.logger.warn(
|
|
3860
|
+
`No lock to force release for stack: ${stackName}${region ? ` (${region})` : ""}`
|
|
3861
|
+
);
|
|
3614
3862
|
return;
|
|
3615
3863
|
}
|
|
3616
3864
|
this.logger.warn(
|
|
3617
|
-
`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)}`
|
|
3618
3866
|
);
|
|
3619
|
-
await this.deleteLock(stackName);
|
|
3867
|
+
await this.deleteLock(stackName, region);
|
|
3620
3868
|
}
|
|
3621
3869
|
/**
|
|
3622
3870
|
* Internal method to delete the lock file from S3
|
|
3623
3871
|
*/
|
|
3624
|
-
async deleteLock(stackName) {
|
|
3625
|
-
const key = this.getLockKey(stackName);
|
|
3872
|
+
async deleteLock(stackName, region) {
|
|
3873
|
+
const key = this.getLockKey(stackName, region);
|
|
3626
3874
|
await this.s3Client.send(
|
|
3627
3875
|
new DeleteObjectCommand2({
|
|
3628
3876
|
Bucket: this.config.bucket,
|
|
@@ -3643,28 +3891,28 @@ var LockManager = class {
|
|
|
3643
3891
|
* @param maxRetries Maximum number of retries (default: 3)
|
|
3644
3892
|
* @param retryDelay Delay between retries in milliseconds (default: 2000)
|
|
3645
3893
|
*/
|
|
3646
|
-
async acquireLockWithRetry(stackName, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3894
|
+
async acquireLockWithRetry(stackName, region, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3647
3895
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3648
|
-
const acquired = await this.acquireLock(stackName, owner, operation);
|
|
3896
|
+
const acquired = await this.acquireLock(stackName, region, owner, operation);
|
|
3649
3897
|
if (acquired) {
|
|
3650
3898
|
return;
|
|
3651
3899
|
}
|
|
3652
|
-
const lockInfo2 = await this.getLockInfo(stackName);
|
|
3900
|
+
const lockInfo2 = await this.getLockInfo(stackName, region);
|
|
3653
3901
|
if (lockInfo2) {
|
|
3654
3902
|
const remainingMs = lockInfo2.expiresAt - Date.now();
|
|
3655
3903
|
if (attempt < maxRetries) {
|
|
3656
3904
|
this.logger.info(
|
|
3657
|
-
`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})`
|
|
3658
3906
|
);
|
|
3659
3907
|
await new Promise((resolve4) => setTimeout(resolve4, retryDelay));
|
|
3660
3908
|
continue;
|
|
3661
3909
|
}
|
|
3662
3910
|
}
|
|
3663
3911
|
}
|
|
3664
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3912
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3665
3913
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
3666
3914
|
throw new LockError(
|
|
3667
|
-
`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.")
|
|
3668
3916
|
);
|
|
3669
3917
|
}
|
|
3670
3918
|
};
|
|
@@ -5447,35 +5695,45 @@ var IntrinsicFunctionResolver = class {
|
|
|
5447
5695
|
}
|
|
5448
5696
|
this.logger.debug(`Resolving Fn::ImportValue: ${exportName}`);
|
|
5449
5697
|
const allStacks = await context.stateBackend.listStacks();
|
|
5450
|
-
this.logger.debug(
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
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}`);
|
|
5454
5705
|
continue;
|
|
5455
5706
|
}
|
|
5456
5707
|
try {
|
|
5457
|
-
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);
|
|
5458
5716
|
if (!stateData) {
|
|
5459
|
-
this.logger.debug(`No state found for stack: ${
|
|
5717
|
+
this.logger.debug(`No state found for stack: ${refStack} (${lookupRegion})`);
|
|
5460
5718
|
continue;
|
|
5461
5719
|
}
|
|
5462
5720
|
const { state } = stateData;
|
|
5463
5721
|
if (state.outputs && exportName in state.outputs) {
|
|
5464
5722
|
const value = state.outputs[exportName];
|
|
5465
5723
|
this.logger.info(
|
|
5466
|
-
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${
|
|
5724
|
+
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${refStack} / ${lookupRegion})`
|
|
5467
5725
|
);
|
|
5468
5726
|
return value;
|
|
5469
5727
|
}
|
|
5470
5728
|
} catch (error) {
|
|
5471
5729
|
this.logger.warn(
|
|
5472
|
-
`Failed to read state for stack ${
|
|
5730
|
+
`Failed to read state for stack ${refStack}: ${error instanceof Error ? error.message : String(error)}`
|
|
5473
5731
|
);
|
|
5474
5732
|
continue;
|
|
5475
5733
|
}
|
|
5476
5734
|
}
|
|
5477
5735
|
throw new Error(
|
|
5478
|
-
`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.`
|
|
5479
5737
|
);
|
|
5480
5738
|
}
|
|
5481
5739
|
/**
|
|
@@ -26256,7 +26514,11 @@ var DeployEngine = class {
|
|
|
26256
26514
|
logger = getLogger().child("DeployEngine");
|
|
26257
26515
|
resolver;
|
|
26258
26516
|
interrupted = false;
|
|
26259
|
-
/**
|
|
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
|
+
*/
|
|
26260
26522
|
stackRegion;
|
|
26261
26523
|
/**
|
|
26262
26524
|
* Deploy a CloudFormation template
|
|
@@ -26265,7 +26527,7 @@ var DeployEngine = class {
|
|
|
26265
26527
|
const startTime = Date.now();
|
|
26266
26528
|
this.logger.debug(`Starting deployment for stack: ${stackName}`);
|
|
26267
26529
|
setCurrentStackName(stackName);
|
|
26268
|
-
await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
|
|
26530
|
+
await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
|
|
26269
26531
|
const renderer = getLiveRenderer();
|
|
26270
26532
|
renderer.start();
|
|
26271
26533
|
this.interrupted = false;
|
|
@@ -26279,16 +26541,17 @@ var DeployEngine = class {
|
|
|
26279
26541
|
};
|
|
26280
26542
|
process.on("SIGINT", sigintHandler);
|
|
26281
26543
|
try {
|
|
26282
|
-
const currentStateData = await this.stateBackend.getState(stackName);
|
|
26544
|
+
const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
26283
26545
|
const currentState = currentStateData?.state ?? {
|
|
26284
|
-
version:
|
|
26285
|
-
|
|
26546
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26547
|
+
region: this.stackRegion,
|
|
26286
26548
|
stackName,
|
|
26287
26549
|
resources: {},
|
|
26288
26550
|
outputs: {},
|
|
26289
26551
|
lastModified: Date.now()
|
|
26290
26552
|
};
|
|
26291
26553
|
const currentEtag = currentStateData?.etag;
|
|
26554
|
+
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
26292
26555
|
this.logger.debug(
|
|
26293
26556
|
`Loaded current state: ${Object.keys(currentState.resources).length} resources`
|
|
26294
26557
|
);
|
|
@@ -26374,9 +26637,10 @@ var DeployEngine = class {
|
|
|
26374
26637
|
parameterValues,
|
|
26375
26638
|
conditions,
|
|
26376
26639
|
currentEtag,
|
|
26377
|
-
progress
|
|
26640
|
+
progress,
|
|
26641
|
+
migrationPending
|
|
26378
26642
|
);
|
|
26379
|
-
const newEtag = await this.stateBackend.saveState(stackName, newState);
|
|
26643
|
+
const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
|
|
26380
26644
|
this.logger.debug(`State saved (ETag: ${newEtag})`);
|
|
26381
26645
|
const durationMs = Date.now() - startTime;
|
|
26382
26646
|
const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
|
|
@@ -26392,7 +26656,7 @@ var DeployEngine = class {
|
|
|
26392
26656
|
renderer.stop();
|
|
26393
26657
|
process.removeListener("SIGINT", sigintHandler);
|
|
26394
26658
|
try {
|
|
26395
|
-
await this.lockManager.releaseLock(stackName);
|
|
26659
|
+
await this.lockManager.releaseLock(stackName, this.stackRegion);
|
|
26396
26660
|
this.logger.debug("Lock released");
|
|
26397
26661
|
} catch (lockError) {
|
|
26398
26662
|
this.logger.warn(
|
|
@@ -26410,11 +26674,12 @@ var DeployEngine = class {
|
|
|
26410
26674
|
* - DELETE follows reverse dependency order (a node starts as soon as all
|
|
26411
26675
|
* resources that depend ON it have finished deleting)
|
|
26412
26676
|
*/
|
|
26413
|
-
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) {
|
|
26414
26678
|
const concurrency = this.options.concurrency;
|
|
26415
26679
|
const newResources = { ...currentState.resources };
|
|
26416
26680
|
const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
|
|
26417
26681
|
const completedOperations = [];
|
|
26682
|
+
let pendingMigration = migrationPending;
|
|
26418
26683
|
let saveChain = Promise.resolve();
|
|
26419
26684
|
const saveStateAfterResource = (logicalId) => {
|
|
26420
26685
|
if (currentEtag === void 0)
|
|
@@ -26422,14 +26687,23 @@ var DeployEngine = class {
|
|
|
26422
26687
|
saveChain = saveChain.then(async () => {
|
|
26423
26688
|
try {
|
|
26424
26689
|
const partialState = {
|
|
26425
|
-
version:
|
|
26426
|
-
|
|
26690
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26691
|
+
region: this.stackRegion,
|
|
26427
26692
|
stackName: currentState.stackName,
|
|
26428
26693
|
resources: newResources,
|
|
26429
26694
|
outputs: currentState.outputs,
|
|
26430
26695
|
lastModified: Date.now()
|
|
26431
26696
|
};
|
|
26432
|
-
|
|
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;
|
|
26433
26707
|
this.logger.debug(`State saved after ${logicalId}`);
|
|
26434
26708
|
} catch (error) {
|
|
26435
26709
|
this.logger.warn(
|
|
@@ -26563,14 +26837,23 @@ var DeployEngine = class {
|
|
|
26563
26837
|
} catch (error) {
|
|
26564
26838
|
try {
|
|
26565
26839
|
const preRollbackState = {
|
|
26566
|
-
version:
|
|
26567
|
-
|
|
26840
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26841
|
+
region: this.stackRegion,
|
|
26568
26842
|
stackName: currentState.stackName,
|
|
26569
26843
|
resources: newResources,
|
|
26570
26844
|
outputs: currentState.outputs,
|
|
26571
26845
|
lastModified: Date.now()
|
|
26572
26846
|
};
|
|
26573
|
-
|
|
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;
|
|
26574
26857
|
this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
|
|
26575
26858
|
} catch (saveError) {
|
|
26576
26859
|
this.logger.warn(
|
|
@@ -26591,31 +26874,35 @@ var DeployEngine = class {
|
|
|
26591
26874
|
}
|
|
26592
26875
|
try {
|
|
26593
26876
|
const postRollbackState = {
|
|
26594
|
-
version:
|
|
26595
|
-
|
|
26877
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26878
|
+
region: this.stackRegion,
|
|
26596
26879
|
stackName: currentState.stackName,
|
|
26597
26880
|
resources: newResources,
|
|
26598
26881
|
outputs: currentState.outputs,
|
|
26599
26882
|
lastModified: Date.now()
|
|
26600
26883
|
};
|
|
26601
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
26884
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
26885
|
+
...currentEtag !== void 0 && { expectedEtag: currentEtag }
|
|
26886
|
+
});
|
|
26602
26887
|
this.logger.debug("State saved after deployment failure");
|
|
26603
26888
|
} catch (saveError) {
|
|
26604
26889
|
this.logger.debug(
|
|
26605
26890
|
`Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
|
|
26606
26891
|
);
|
|
26607
26892
|
try {
|
|
26608
|
-
const freshState = await this.stateBackend.getState(stackName);
|
|
26893
|
+
const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
26609
26894
|
const freshEtag = freshState?.etag;
|
|
26610
26895
|
const postRollbackState = {
|
|
26611
|
-
version:
|
|
26612
|
-
|
|
26896
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26897
|
+
region: this.stackRegion,
|
|
26613
26898
|
stackName: currentState.stackName,
|
|
26614
26899
|
resources: newResources,
|
|
26615
26900
|
outputs: currentState.outputs,
|
|
26616
26901
|
lastModified: Date.now()
|
|
26617
26902
|
};
|
|
26618
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
26903
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
26904
|
+
...freshEtag !== void 0 && { expectedEtag: freshEtag }
|
|
26905
|
+
});
|
|
26619
26906
|
this.logger.debug("State saved after deployment failure (retry succeeded)");
|
|
26620
26907
|
} catch (retryError) {
|
|
26621
26908
|
this.logger.warn(
|
|
@@ -26634,8 +26921,8 @@ var DeployEngine = class {
|
|
|
26634
26921
|
);
|
|
26635
26922
|
return {
|
|
26636
26923
|
state: {
|
|
26637
|
-
version:
|
|
26638
|
-
|
|
26924
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
26925
|
+
region: this.stackRegion,
|
|
26639
26926
|
stackName: currentState.stackName,
|
|
26640
26927
|
resources: newResources,
|
|
26641
26928
|
outputs,
|
|
@@ -27642,19 +27929,18 @@ async function diffCommand(stacks, options) {
|
|
|
27642
27929
|
logger.info(`
|
|
27643
27930
|
Calculating diff for stack: ${stackInfo.stackName}`);
|
|
27644
27931
|
const template = stackInfo.template;
|
|
27645
|
-
|
|
27646
|
-
const stateResult = await stateBackend.getState(stackInfo.stackName);
|
|
27647
|
-
|
|
27648
|
-
|
|
27649
|
-
|
|
27650
|
-
|
|
27651
|
-
|
|
27652
|
-
|
|
27653
|
-
|
|
27654
|
-
|
|
27655
|
-
|
|
27656
|
-
|
|
27657
|
-
};
|
|
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})`);
|
|
27658
27944
|
}
|
|
27659
27945
|
const diffResolveFn = (value) => intrinsicResolver.resolve(value, {
|
|
27660
27946
|
template,
|
|
@@ -27776,19 +28062,27 @@ async function destroyCommand(stackArgs, options) {
|
|
|
27776
28062
|
});
|
|
27777
28063
|
appStacks = result.stacks.map((s) => ({
|
|
27778
28064
|
stackName: s.stackName,
|
|
27779
|
-
displayName: s.displayName
|
|
28065
|
+
displayName: s.displayName,
|
|
28066
|
+
...s.region && { region: s.region }
|
|
27780
28067
|
}));
|
|
27781
28068
|
} catch {
|
|
27782
28069
|
logger.debug("Could not synthesize app, falling back to state-based stack list");
|
|
27783
28070
|
}
|
|
27784
28071
|
}
|
|
27785
|
-
const
|
|
28072
|
+
const allStateRefs = await stateBackend.listStacks();
|
|
27786
28073
|
let candidateStacks;
|
|
27787
28074
|
if (appStacks.length > 0) {
|
|
27788
|
-
const
|
|
27789
|
-
candidateStacks = appStacks.filter((s) =>
|
|
28075
|
+
const stateNames = new Set(allStateRefs.map((r) => r.stackName));
|
|
28076
|
+
candidateStacks = appStacks.filter((s) => stateNames.has(s.stackName));
|
|
27790
28077
|
} else if (stackArgs.length > 0 || options.stack || options.all) {
|
|
27791
|
-
|
|
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
|
+
}
|
|
27792
28086
|
} else {
|
|
27793
28087
|
throw new Error(
|
|
27794
28088
|
"Could not determine which stacks belong to this app. Specify stack names explicitly, use --all, or ensure --app / cdk.json is configured."
|
|
@@ -27815,10 +28109,38 @@ async function destroyCommand(stackArgs, options) {
|
|
|
27815
28109
|
return;
|
|
27816
28110
|
}
|
|
27817
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
|
+
}
|
|
27818
28118
|
for (const stackName of stackNames) {
|
|
27819
28119
|
logger.info(`
|
|
27820
28120
|
Preparing to destroy stack: ${stackName}`);
|
|
27821
|
-
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);
|
|
27822
28144
|
if (!stateResult) {
|
|
27823
28145
|
logger.warn(`No state found for stack ${stackName}, skipping`);
|
|
27824
28146
|
continue;
|
|
@@ -27827,7 +28149,7 @@ Preparing to destroy stack: ${stackName}`);
|
|
|
27827
28149
|
const resourceCount = Object.keys(currentState.resources).length;
|
|
27828
28150
|
if (resourceCount === 0) {
|
|
27829
28151
|
logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
|
|
27830
|
-
await stateBackend.deleteState(stackName);
|
|
28152
|
+
await stateBackend.deleteState(stackName, stackTargetRegion);
|
|
27831
28153
|
logger.info("\u2713 State deleted");
|
|
27832
28154
|
continue;
|
|
27833
28155
|
}
|
|
@@ -27852,7 +28174,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
|
|
|
27852
28174
|
continue;
|
|
27853
28175
|
}
|
|
27854
28176
|
}
|
|
27855
|
-
const stackRegion =
|
|
28177
|
+
const stackRegion = stackTargetRegion;
|
|
27856
28178
|
let destroyProviderRegistry = providerRegistry;
|
|
27857
28179
|
let destroyAwsClients;
|
|
27858
28180
|
if (stackRegion && stackRegion !== region) {
|
|
@@ -27870,7 +28192,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
|
|
|
27870
28192
|
}
|
|
27871
28193
|
logger.info(`
|
|
27872
28194
|
Acquiring lock for stack ${stackName}...`);
|
|
27873
|
-
await lockManager.acquireLock(stackName, "destroy");
|
|
28195
|
+
await lockManager.acquireLock(stackName, stackRegion, void 0, "destroy");
|
|
27874
28196
|
const renderer = getLiveRenderer();
|
|
27875
28197
|
renderer.start();
|
|
27876
28198
|
try {
|
|
@@ -27985,7 +28307,7 @@ Acquiring lock for stack ${stackName}...`);
|
|
|
27985
28307
|
await Promise.all(deletePromises);
|
|
27986
28308
|
}
|
|
27987
28309
|
if (errorCount === 0) {
|
|
27988
|
-
await stateBackend.deleteState(stackName);
|
|
28310
|
+
await stateBackend.deleteState(stackName, stackRegion);
|
|
27989
28311
|
logger.debug("State deleted");
|
|
27990
28312
|
} else {
|
|
27991
28313
|
logger.warn(`${errorCount} resource(s) failed to delete. State preserved.`);
|
|
@@ -27997,7 +28319,7 @@ Acquiring lock for stack ${stackName}...`);
|
|
|
27997
28319
|
} finally {
|
|
27998
28320
|
renderer.stop();
|
|
27999
28321
|
logger.debug("Releasing lock...");
|
|
28000
|
-
await lockManager.releaseLock(stackName);
|
|
28322
|
+
await lockManager.releaseLock(stackName, stackRegion);
|
|
28001
28323
|
if (destroyAwsClients) {
|
|
28002
28324
|
destroyAwsClients.destroy();
|
|
28003
28325
|
process.env["AWS_REGION"] = region;
|
|
@@ -28058,7 +28380,7 @@ function createPublishAssetsCommand() {
|
|
|
28058
28380
|
}
|
|
28059
28381
|
|
|
28060
28382
|
// src/cli/commands/force-unlock.ts
|
|
28061
|
-
import { Command as Command8 } from "commander";
|
|
28383
|
+
import { Command as Command8, Option as Option3 } from "commander";
|
|
28062
28384
|
init_aws_clients();
|
|
28063
28385
|
async function forceUnlockCommand(stackArgs, options) {
|
|
28064
28386
|
const logger = getLogger();
|
|
@@ -28082,17 +28404,33 @@ async function forceUnlockCommand(stackArgs, options) {
|
|
|
28082
28404
|
prefix: options.statePrefix
|
|
28083
28405
|
};
|
|
28084
28406
|
const lockManager = new LockManager(awsClients.s3, stateConfig);
|
|
28407
|
+
const stateBackend = new S3StateBackend(awsClients.s3, stateConfig);
|
|
28085
28408
|
for (const stackName of stackPatterns) {
|
|
28086
|
-
|
|
28087
|
-
|
|
28088
|
-
|
|
28089
|
-
|
|
28090
|
-
|
|
28091
|
-
const
|
|
28092
|
-
if (
|
|
28093
|
-
|
|
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];
|
|
28094
28417
|
} else {
|
|
28095
|
-
|
|
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
|
+
}
|
|
28096
28434
|
}
|
|
28097
28435
|
}
|
|
28098
28436
|
}
|
|
@@ -28101,15 +28439,47 @@ async function forceUnlockCommand(stackArgs, options) {
|
|
|
28101
28439
|
}
|
|
28102
28440
|
}
|
|
28103
28441
|
function createForceUnlockCommand() {
|
|
28104
|
-
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));
|
|
28105
28448
|
[...commonOptions, ...stateOptions, ...stackOptions].forEach((opt) => cmd.addOption(opt));
|
|
28106
28449
|
return cmd;
|
|
28107
28450
|
}
|
|
28108
28451
|
|
|
28109
28452
|
// src/cli/commands/state.ts
|
|
28110
28453
|
import * as readline2 from "node:readline/promises";
|
|
28111
|
-
import { Command as Command9 } from "commander";
|
|
28454
|
+
import { Command as Command9, Option as Option4 } from "commander";
|
|
28112
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
|
+
}
|
|
28113
28483
|
async function setupStateBackend(options) {
|
|
28114
28484
|
const awsClients = new AwsClients({
|
|
28115
28485
|
...options.region && { region: options.region },
|
|
@@ -28131,34 +28501,52 @@ async function setupStateBackend(options) {
|
|
|
28131
28501
|
dispose: () => awsClients.destroy()
|
|
28132
28502
|
};
|
|
28133
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
|
+
}
|
|
28134
28519
|
async function stateListCommand(options) {
|
|
28135
28520
|
const logger = getLogger();
|
|
28136
28521
|
if (options.verbose)
|
|
28137
28522
|
logger.setLevel("debug");
|
|
28138
28523
|
const setup = await setupStateBackend(options);
|
|
28139
28524
|
try {
|
|
28140
|
-
const
|
|
28525
|
+
const refs = sortRefs(await setup.stateBackend.listStacks());
|
|
28141
28526
|
if (!options.long && !options.json) {
|
|
28142
|
-
for (const
|
|
28143
|
-
process.stdout.write(`${
|
|
28527
|
+
for (const ref of refs) {
|
|
28528
|
+
process.stdout.write(`${formatStackRef(ref)}
|
|
28144
28529
|
`);
|
|
28145
28530
|
}
|
|
28146
28531
|
return;
|
|
28147
28532
|
}
|
|
28148
28533
|
if (options.json && !options.long) {
|
|
28149
|
-
|
|
28534
|
+
const payload = refs.map((r) => ({ stackName: r.stackName, region: r.region ?? null }));
|
|
28535
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
28150
28536
|
`);
|
|
28151
28537
|
return;
|
|
28152
28538
|
}
|
|
28153
28539
|
const details = await Promise.all(
|
|
28154
|
-
|
|
28540
|
+
refs.map(async (ref) => {
|
|
28541
|
+
const lookupRegion = ref.region ?? "";
|
|
28155
28542
|
const [stateResult, locked] = await Promise.all([
|
|
28156
|
-
setup.stateBackend.getState(stackName),
|
|
28157
|
-
setup.lockManager.isLocked(stackName)
|
|
28543
|
+
lookupRegion ? setup.stateBackend.getState(ref.stackName, lookupRegion) : Promise.resolve(null),
|
|
28544
|
+
setup.lockManager.isLocked(ref.stackName, ref.region)
|
|
28158
28545
|
]);
|
|
28159
28546
|
const state = stateResult?.state;
|
|
28160
28547
|
return {
|
|
28161
|
-
stackName,
|
|
28548
|
+
stackName: ref.stackName,
|
|
28549
|
+
region: ref.region ?? null,
|
|
28162
28550
|
resourceCount: state ? Object.keys(state.resources).length : 0,
|
|
28163
28551
|
lastModified: state && typeof state.lastModified === "number" ? new Date(state.lastModified).toISOString() : null,
|
|
28164
28552
|
locked
|
|
@@ -28172,7 +28560,13 @@ async function stateListCommand(options) {
|
|
|
28172
28560
|
}
|
|
28173
28561
|
const lines = [];
|
|
28174
28562
|
for (const detail of details) {
|
|
28175
|
-
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)"}`);
|
|
28176
28570
|
lines.push(` Resources: ${detail.resourceCount}`);
|
|
28177
28571
|
lines.push(` Last Modified: ${detail.lastModified ?? "unknown"}`);
|
|
28178
28572
|
lines.push(` Lock: ${detail.locked ? "locked" : "unlocked"}`);
|
|
@@ -28200,10 +28594,17 @@ async function stateResourcesCommand(stackName, options) {
|
|
|
28200
28594
|
logger.setLevel("debug");
|
|
28201
28595
|
const setup = await setupStateBackend(options);
|
|
28202
28596
|
try {
|
|
28203
|
-
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);
|
|
28204
28605
|
if (!stateResult) {
|
|
28205
28606
|
throw new Error(
|
|
28206
|
-
`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.`
|
|
28207
28608
|
);
|
|
28208
28609
|
}
|
|
28209
28610
|
const resources = stateResult.state.resources ?? {};
|
|
@@ -28286,7 +28687,7 @@ function formatLockSummary(lockInfo) {
|
|
|
28286
28687
|
return `locked by ${lockInfo.owner}${opStr}, ${expiresStr}`;
|
|
28287
28688
|
}
|
|
28288
28689
|
function createStateResourcesCommand() {
|
|
28289
|
-
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));
|
|
28290
28691
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28291
28692
|
return cmd;
|
|
28292
28693
|
}
|
|
@@ -28296,13 +28697,20 @@ async function stateShowCommand(stackName, options) {
|
|
|
28296
28697
|
logger.setLevel("debug");
|
|
28297
28698
|
const setup = await setupStateBackend(options);
|
|
28298
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
|
+
}
|
|
28299
28707
|
const [stateResult, lockInfo] = await Promise.all([
|
|
28300
|
-
setup.stateBackend.getState(stackName),
|
|
28301
|
-
setup.lockManager.getLockInfo(stackName)
|
|
28708
|
+
setup.stateBackend.getState(stackName, ref.region),
|
|
28709
|
+
setup.lockManager.getLockInfo(stackName, ref.region)
|
|
28302
28710
|
]);
|
|
28303
28711
|
if (!stateResult) {
|
|
28304
28712
|
throw new Error(
|
|
28305
|
-
`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.`
|
|
28306
28714
|
);
|
|
28307
28715
|
}
|
|
28308
28716
|
if (options.json) {
|
|
@@ -28366,7 +28774,7 @@ async function stateShowCommand(stackName, options) {
|
|
|
28366
28774
|
}
|
|
28367
28775
|
}
|
|
28368
28776
|
function createStateShowCommand() {
|
|
28369
|
-
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));
|
|
28370
28778
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28371
28779
|
return cmd;
|
|
28372
28780
|
}
|
|
@@ -28379,24 +28787,36 @@ async function stateRmCommand(stackArgs, options) {
|
|
|
28379
28787
|
}
|
|
28380
28788
|
const setup = await setupStateBackend(options);
|
|
28381
28789
|
try {
|
|
28790
|
+
const refs = await setup.stateBackend.listStacks();
|
|
28382
28791
|
for (const stackName of stackArgs) {
|
|
28383
|
-
const
|
|
28384
|
-
if (
|
|
28792
|
+
const stackRefs = refs.filter((r) => r.stackName === stackName);
|
|
28793
|
+
if (stackRefs.length === 0) {
|
|
28385
28794
|
logger.info(`No state found for stack: ${stackName}, skipping`);
|
|
28386
28795
|
continue;
|
|
28387
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
|
+
}
|
|
28388
28804
|
if (!options.force) {
|
|
28389
|
-
const
|
|
28390
|
-
|
|
28391
|
-
|
|
28392
|
-
|
|
28393
|
-
|
|
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
|
+
}
|
|
28394
28813
|
}
|
|
28395
28814
|
}
|
|
28396
28815
|
if (!options.yes && !options.force) {
|
|
28816
|
+
const targetList = targets.map((t) => formatStackRef(t)).join(", ");
|
|
28397
28817
|
process.stdout.write(
|
|
28398
28818
|
`
|
|
28399
|
-
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.
|
|
28400
28820
|
Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
28401
28821
|
|
|
28402
28822
|
`
|
|
@@ -28406,7 +28826,7 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
|
28406
28826
|
output: process.stdout
|
|
28407
28827
|
});
|
|
28408
28828
|
const answer = await rl.question(
|
|
28409
|
-
`Remove state for
|
|
28829
|
+
`Remove state for ${targetList} from s3://${setup.bucket}/${setup.prefix}/? (y/N): `
|
|
28410
28830
|
);
|
|
28411
28831
|
rl.close();
|
|
28412
28832
|
const trimmed = answer.trim().toLowerCase();
|
|
@@ -28415,16 +28835,28 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
|
|
|
28415
28835
|
continue;
|
|
28416
28836
|
}
|
|
28417
28837
|
}
|
|
28418
|
-
|
|
28419
|
-
|
|
28420
|
-
|
|
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
|
+
}
|
|
28421
28847
|
}
|
|
28422
28848
|
} finally {
|
|
28423
28849
|
setup.dispose();
|
|
28424
28850
|
}
|
|
28425
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
|
+
}
|
|
28426
28858
|
function createStateRmCommand() {
|
|
28427
|
-
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));
|
|
28428
28860
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
28429
28861
|
return cmd;
|
|
28430
28862
|
}
|
|
@@ -28462,7 +28894,7 @@ function reorderArgs(argv) {
|
|
|
28462
28894
|
}
|
|
28463
28895
|
async function main() {
|
|
28464
28896
|
const program = new Command10();
|
|
28465
|
-
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");
|
|
28466
28898
|
program.addCommand(createBootstrapCommand());
|
|
28467
28899
|
program.addCommand(createSynthCommand());
|
|
28468
28900
|
program.addCommand(createListCommand());
|