@go-to-k/cdkd 0.7.0 → 0.9.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 +1664 -389
- package/dist/cli.js.map +4 -4
- package/dist/go-to-k-cdkd-0.9.0.tgz +0 -0
- package/dist/index.js +491 -160
- 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/index.js
CHANGED
|
@@ -2661,6 +2661,14 @@ import {
|
|
|
2661
2661
|
ListObjectsV2Command,
|
|
2662
2662
|
NoSuchKey
|
|
2663
2663
|
} from "@aws-sdk/client-s3";
|
|
2664
|
+
|
|
2665
|
+
// src/types/state.ts
|
|
2666
|
+
var STATE_SCHEMA_VERSION_LEGACY = 1;
|
|
2667
|
+
var STATE_SCHEMA_VERSION_CURRENT = 2;
|
|
2668
|
+
|
|
2669
|
+
// src/state/s3-state-backend.ts
|
|
2670
|
+
var LEGACY_KEY_DEPTH = 2;
|
|
2671
|
+
var NEW_KEY_DEPTH = 3;
|
|
2664
2672
|
var S3StateBackend = class {
|
|
2665
2673
|
constructor(s3Client, config) {
|
|
2666
2674
|
this.s3Client = s3Client;
|
|
@@ -2668,9 +2676,16 @@ var S3StateBackend = class {
|
|
|
2668
2676
|
}
|
|
2669
2677
|
logger = getLogger().child("S3StateBackend");
|
|
2670
2678
|
/**
|
|
2671
|
-
* Get the S3 key for a stack's state file
|
|
2679
|
+
* Get the new (region-scoped) S3 key for a stack's state file.
|
|
2680
|
+
*/
|
|
2681
|
+
getStateKey(stackName, region) {
|
|
2682
|
+
return `${this.config.prefix}/${stackName}/${region}/state.json`;
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Get the legacy (pre-region-prefix) S3 key for a stack's state file.
|
|
2686
|
+
* Used for backwards-compatible reads and for the migration delete.
|
|
2672
2687
|
*/
|
|
2673
|
-
|
|
2688
|
+
getLegacyStateKey(stackName) {
|
|
2674
2689
|
return `${this.config.prefix}/${stackName}/state.json`;
|
|
2675
2690
|
}
|
|
2676
2691
|
/**
|
|
@@ -2696,101 +2711,143 @@ var S3StateBackend = class {
|
|
|
2696
2711
|
}
|
|
2697
2712
|
}
|
|
2698
2713
|
/**
|
|
2699
|
-
* Check if state exists for a stack
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
);
|
|
2714
|
+
* Check if state exists for a stack in the given region.
|
|
2715
|
+
*
|
|
2716
|
+
* Returns true for either layout: the new region-scoped key, or the legacy
|
|
2717
|
+
* key when its embedded `region` matches the requested region. This lets
|
|
2718
|
+
* `cdkd state rm <stack> --region X` and `cdkd destroy <stack>` see legacy
|
|
2719
|
+
* state without forcing a write-through migration first.
|
|
2720
|
+
*/
|
|
2721
|
+
async stateExists(stackName, region) {
|
|
2722
|
+
const newKey = this.getStateKey(stackName, region);
|
|
2723
|
+
if (await this.headObject(newKey)) {
|
|
2710
2724
|
return true;
|
|
2711
|
-
} catch (error) {
|
|
2712
|
-
if (error instanceof NoSuchKey || error.name === "NotFound") {
|
|
2713
|
-
return false;
|
|
2714
|
-
}
|
|
2715
|
-
throw new StateError(
|
|
2716
|
-
`Failed to check if state exists for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
2717
|
-
error instanceof Error ? error : void 0
|
|
2718
|
-
);
|
|
2719
2725
|
}
|
|
2726
|
+
return this.legacyMatchesRegion(stackName, region);
|
|
2720
2727
|
}
|
|
2721
2728
|
/**
|
|
2722
|
-
* Get state for a stack
|
|
2729
|
+
* Get state for a stack, transparently falling back to the legacy key.
|
|
2723
2730
|
*
|
|
2724
|
-
*
|
|
2725
|
-
*
|
|
2731
|
+
* Lookup order:
|
|
2732
|
+
* 1. `{prefix}/{stackName}/{region}/state.json` (current `version: 2` key).
|
|
2733
|
+
* 2. `{prefix}/{stackName}/state.json` (legacy `version: 1` key) — only
|
|
2734
|
+
* accepted if its embedded `region` matches the requested region.
|
|
2735
|
+
*
|
|
2736
|
+
* When a legacy hit is returned, `migrationPending` is `true`. Callers that
|
|
2737
|
+
* subsequently `saveState` automatically migrate by writing the new key and
|
|
2738
|
+
* deleting the legacy one (see `saveState`'s `legacyMigration` argument).
|
|
2739
|
+
*
|
|
2740
|
+
* Note: S3 returns ETag with surrounding quotes (e.g., `"abc123"`). We
|
|
2741
|
+
* preserve the quotes — they are required for `IfMatch` conditions.
|
|
2726
2742
|
*/
|
|
2727
|
-
async getState(stackName) {
|
|
2728
|
-
const
|
|
2743
|
+
async getState(stackName, region) {
|
|
2744
|
+
const newKey = this.getStateKey(stackName, region);
|
|
2729
2745
|
try {
|
|
2730
|
-
this.logger.debug(`Getting state for stack: ${stackName}`);
|
|
2746
|
+
this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
|
|
2731
2747
|
const response = await this.s3Client.send(
|
|
2732
2748
|
new GetObjectCommand({
|
|
2733
2749
|
Bucket: this.config.bucket,
|
|
2734
|
-
Key:
|
|
2750
|
+
Key: newKey
|
|
2735
2751
|
})
|
|
2736
2752
|
);
|
|
2737
2753
|
if (!response.Body) {
|
|
2738
|
-
throw new StateError(`State file for stack '${stackName}' has no body`);
|
|
2754
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no body`);
|
|
2739
2755
|
}
|
|
2740
2756
|
if (!response.ETag) {
|
|
2741
|
-
throw new StateError(`State file for stack '${stackName}' has no ETag`);
|
|
2757
|
+
throw new StateError(`State file for stack '${stackName}' (${region}) has no ETag`);
|
|
2742
2758
|
}
|
|
2743
2759
|
const bodyString = await response.Body.transformToString();
|
|
2744
|
-
const state =
|
|
2745
|
-
this.logger.debug(`Retrieved state
|
|
2746
|
-
return {
|
|
2747
|
-
state,
|
|
2748
|
-
etag: response.ETag
|
|
2749
|
-
};
|
|
2760
|
+
const state = this.parseStateBody(bodyString, stackName);
|
|
2761
|
+
this.logger.debug(`Retrieved state: ${stackName} (${region}), ETag: ${response.ETag}`);
|
|
2762
|
+
return { state, etag: response.ETag };
|
|
2750
2763
|
} catch (error) {
|
|
2751
|
-
if (error
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2764
|
+
if (!isNoSuchKey(error)) {
|
|
2765
|
+
if (error instanceof StateError)
|
|
2766
|
+
throw error;
|
|
2767
|
+
throw new StateError(
|
|
2768
|
+
`Failed to get state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
2769
|
+
error instanceof Error ? error : void 0
|
|
2770
|
+
);
|
|
2757
2771
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2772
|
+
this.logger.debug(`No state at new key for stack: ${stackName} (${region})`);
|
|
2773
|
+
}
|
|
2774
|
+
const legacy = await this.tryGetLegacy(stackName, region);
|
|
2775
|
+
if (legacy) {
|
|
2776
|
+
this.logger.warn(
|
|
2777
|
+
`Loaded legacy state for stack '${stackName}' from '${this.getLegacyStateKey(stackName)}'. It will be migrated to the region-scoped layout on next save.`
|
|
2761
2778
|
);
|
|
2779
|
+
return { ...legacy, migrationPending: true };
|
|
2762
2780
|
}
|
|
2781
|
+
return null;
|
|
2763
2782
|
}
|
|
2764
2783
|
/**
|
|
2765
|
-
* Save state for a stack with optimistic locking
|
|
2784
|
+
* Save state for a stack with optimistic locking.
|
|
2785
|
+
*
|
|
2786
|
+
* Always writes to the new region-scoped key. The state body is rewritten
|
|
2787
|
+
* with `version: 2` and the supplied region.
|
|
2788
|
+
*
|
|
2789
|
+
* If the caller observed `migrationPending: true` from `getState`, it
|
|
2790
|
+
* should pass the legacy ETag back via `expectedEtag` AND set
|
|
2791
|
+
* `migrateLegacy: true`. After the new key is written successfully, the
|
|
2792
|
+
* legacy key is deleted to complete migration. The legacy delete is a
|
|
2793
|
+
* best-effort follow-up — a failure is logged but does not unwind the new
|
|
2794
|
+
* write.
|
|
2766
2795
|
*
|
|
2767
2796
|
* @param stackName Stack name
|
|
2797
|
+
* @param region Target region (load-bearing — part of the S3 key)
|
|
2768
2798
|
* @param state State to save
|
|
2769
|
-
* @param
|
|
2770
|
-
*
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
const
|
|
2799
|
+
* @param options Optimistic-lock ETag + legacy-migration flag
|
|
2800
|
+
* @returns New ETag (with quotes, e.g., `"abc123"`)
|
|
2801
|
+
*/
|
|
2802
|
+
async saveState(stackName, region, state, options = {}) {
|
|
2803
|
+
const newKey = this.getStateKey(stackName, region);
|
|
2804
|
+
const { expectedEtag, migrateLegacy } = options;
|
|
2805
|
+
const body = {
|
|
2806
|
+
...state,
|
|
2807
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
2808
|
+
stackName,
|
|
2809
|
+
region
|
|
2810
|
+
};
|
|
2775
2811
|
try {
|
|
2776
2812
|
this.logger.debug(
|
|
2777
|
-
`Saving state
|
|
2813
|
+
`Saving state: ${stackName} (${region})${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
|
|
2778
2814
|
);
|
|
2779
|
-
const
|
|
2815
|
+
const bodyString = JSON.stringify(body, null, 2);
|
|
2780
2816
|
const response = await this.s3Client.send(
|
|
2781
2817
|
new PutObjectCommand2({
|
|
2782
2818
|
Bucket: this.config.bucket,
|
|
2783
|
-
Key:
|
|
2784
|
-
Body:
|
|
2785
|
-
ContentLength: Buffer.byteLength(
|
|
2819
|
+
Key: newKey,
|
|
2820
|
+
Body: bodyString,
|
|
2821
|
+
ContentLength: Buffer.byteLength(bodyString),
|
|
2786
2822
|
ContentType: "application/json",
|
|
2787
|
-
|
|
2823
|
+
// The legacy ETag is for a different key; only forward it when we're
|
|
2824
|
+
// updating in-place at the new key.
|
|
2825
|
+
...!migrateLegacy && expectedEtag && { IfMatch: expectedEtag }
|
|
2788
2826
|
})
|
|
2789
2827
|
);
|
|
2790
2828
|
if (!response.ETag) {
|
|
2791
|
-
throw new StateError(
|
|
2829
|
+
throw new StateError(
|
|
2830
|
+
`No ETag returned after saving state for stack '${stackName}' (${region})`
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
this.logger.debug(`State saved: ${stackName} (${region}), new ETag: ${response.ETag}`);
|
|
2834
|
+
if (migrateLegacy) {
|
|
2835
|
+
try {
|
|
2836
|
+
await this.s3Client.send(
|
|
2837
|
+
new DeleteObjectCommand({
|
|
2838
|
+
Bucket: this.config.bucket,
|
|
2839
|
+
Key: this.getLegacyStateKey(stackName)
|
|
2840
|
+
})
|
|
2841
|
+
);
|
|
2842
|
+
this.logger.info(
|
|
2843
|
+
`Migrated state for stack '${stackName}' to region-scoped layout (${region})`
|
|
2844
|
+
);
|
|
2845
|
+
} catch (deleteError) {
|
|
2846
|
+
this.logger.warn(
|
|
2847
|
+
`Migrated stack '${stackName}' to new key, but failed to delete legacy key: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
|
|
2848
|
+
);
|
|
2849
|
+
}
|
|
2792
2850
|
}
|
|
2793
|
-
this.logger.debug(`State saved for stack: ${stackName}, new ETag: ${response.ETag}`);
|
|
2794
2851
|
return response.ETag;
|
|
2795
2852
|
} catch (error) {
|
|
2796
2853
|
if (error.name === "PreconditionFailed") {
|
|
@@ -2799,63 +2856,230 @@ var S3StateBackend = class {
|
|
|
2799
2856
|
);
|
|
2800
2857
|
}
|
|
2801
2858
|
throw new StateError(
|
|
2802
|
-
`Failed to save state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
2859
|
+
`Failed to save state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
2803
2860
|
error instanceof Error ? error : void 0
|
|
2804
2861
|
);
|
|
2805
2862
|
}
|
|
2806
2863
|
}
|
|
2807
2864
|
/**
|
|
2808
|
-
* Delete state for a stack
|
|
2865
|
+
* Delete state for a stack in the given region.
|
|
2866
|
+
*
|
|
2867
|
+
* Removes both the new key and the legacy key (if present). Legacy removal
|
|
2868
|
+
* is region-conditional: a legacy state file with a different `region`
|
|
2869
|
+
* field is left alone.
|
|
2809
2870
|
*/
|
|
2810
|
-
async deleteState(stackName) {
|
|
2811
|
-
const key = this.getStateKey(stackName);
|
|
2871
|
+
async deleteState(stackName, region) {
|
|
2812
2872
|
try {
|
|
2813
|
-
this.logger.debug(`Deleting state
|
|
2873
|
+
this.logger.debug(`Deleting state: ${stackName} (${region})`);
|
|
2814
2874
|
await this.s3Client.send(
|
|
2815
2875
|
new DeleteObjectCommand({
|
|
2816
2876
|
Bucket: this.config.bucket,
|
|
2817
|
-
Key:
|
|
2877
|
+
Key: this.getStateKey(stackName, region)
|
|
2818
2878
|
})
|
|
2819
2879
|
);
|
|
2820
|
-
this.
|
|
2880
|
+
if (await this.legacyMatchesRegion(stackName, region)) {
|
|
2881
|
+
await this.s3Client.send(
|
|
2882
|
+
new DeleteObjectCommand({
|
|
2883
|
+
Bucket: this.config.bucket,
|
|
2884
|
+
Key: this.getLegacyStateKey(stackName)
|
|
2885
|
+
})
|
|
2886
|
+
);
|
|
2887
|
+
this.logger.debug(`Deleted legacy state for stack: ${stackName}`);
|
|
2888
|
+
}
|
|
2889
|
+
this.logger.debug(`State deleted: ${stackName} (${region})`);
|
|
2821
2890
|
} catch (error) {
|
|
2822
2891
|
throw new StateError(
|
|
2823
|
-
`Failed to delete state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
2892
|
+
`Failed to delete state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
2824
2893
|
error instanceof Error ? error : void 0
|
|
2825
2894
|
);
|
|
2826
2895
|
}
|
|
2827
2896
|
}
|
|
2828
2897
|
/**
|
|
2829
|
-
* List all stacks with state
|
|
2898
|
+
* List all stacks with state in the bucket.
|
|
2899
|
+
*
|
|
2900
|
+
* Returns one `{stackName, region}` pair per state file. Both layouts
|
|
2901
|
+
* are enumerated:
|
|
2902
|
+
*
|
|
2903
|
+
* - `{prefix}/{stackName}/{region}/state.json` (new) — `region` is the
|
|
2904
|
+
* path segment.
|
|
2905
|
+
* - `{prefix}/{stackName}/state.json` (legacy) — `region` is read from the
|
|
2906
|
+
* state body when present, otherwise `undefined`.
|
|
2907
|
+
*
|
|
2908
|
+
* Pairs are deduplicated by `(stackName, region)` so a stack mid-migration
|
|
2909
|
+
* shows up exactly once.
|
|
2830
2910
|
*/
|
|
2831
2911
|
async listStacks() {
|
|
2832
2912
|
try {
|
|
2833
2913
|
this.logger.debug("Listing all stacks");
|
|
2914
|
+
const prefix = `${this.config.prefix}/`;
|
|
2915
|
+
const refs = [];
|
|
2916
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2917
|
+
let continuationToken;
|
|
2918
|
+
do {
|
|
2919
|
+
const response = await this.s3Client.send(
|
|
2920
|
+
new ListObjectsV2Command({
|
|
2921
|
+
Bucket: this.config.bucket,
|
|
2922
|
+
Prefix: prefix,
|
|
2923
|
+
...continuationToken && { ContinuationToken: continuationToken }
|
|
2924
|
+
})
|
|
2925
|
+
);
|
|
2926
|
+
for (const obj of response.Contents ?? []) {
|
|
2927
|
+
const key = obj.Key;
|
|
2928
|
+
if (!key)
|
|
2929
|
+
continue;
|
|
2930
|
+
if (!key.endsWith("/state.json"))
|
|
2931
|
+
continue;
|
|
2932
|
+
const rest = key.slice(prefix.length);
|
|
2933
|
+
const segments = rest.split("/");
|
|
2934
|
+
if (segments.length === NEW_KEY_DEPTH) {
|
|
2935
|
+
const [stackName, region] = segments;
|
|
2936
|
+
if (!stackName || !region)
|
|
2937
|
+
continue;
|
|
2938
|
+
const dedupeKey = `${stackName}\0${region}`;
|
|
2939
|
+
if (!seen.has(dedupeKey)) {
|
|
2940
|
+
seen.add(dedupeKey);
|
|
2941
|
+
refs.push({ stackName, region });
|
|
2942
|
+
}
|
|
2943
|
+
continue;
|
|
2944
|
+
}
|
|
2945
|
+
if (segments.length === LEGACY_KEY_DEPTH) {
|
|
2946
|
+
const [stackName] = segments;
|
|
2947
|
+
if (!stackName)
|
|
2948
|
+
continue;
|
|
2949
|
+
const region = await this.readLegacyRegion(stackName);
|
|
2950
|
+
const dedupeKey = `${stackName}\0${region ?? ""}`;
|
|
2951
|
+
if (!seen.has(dedupeKey)) {
|
|
2952
|
+
seen.add(dedupeKey);
|
|
2953
|
+
refs.push({ stackName, ...region ? { region } : {} });
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
2958
|
+
} while (continuationToken);
|
|
2959
|
+
this.logger.debug(`Found ${refs.length} stack(s) across regions`);
|
|
2960
|
+
return refs;
|
|
2961
|
+
} catch (error) {
|
|
2962
|
+
throw new StateError(
|
|
2963
|
+
`Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
|
|
2964
|
+
error instanceof Error ? error : void 0
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* HeadObject probe — returns true on 200, false on NotFound. Other errors
|
|
2970
|
+
* propagate so we don't accidentally swallow IAM denials.
|
|
2971
|
+
*/
|
|
2972
|
+
async headObject(key) {
|
|
2973
|
+
try {
|
|
2974
|
+
await this.s3Client.send(
|
|
2975
|
+
new HeadObjectCommand2({
|
|
2976
|
+
Bucket: this.config.bucket,
|
|
2977
|
+
Key: key
|
|
2978
|
+
})
|
|
2979
|
+
);
|
|
2980
|
+
return true;
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
if (isNoSuchKey(error) || error.name === "NotFound") {
|
|
2983
|
+
return false;
|
|
2984
|
+
}
|
|
2985
|
+
throw error;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Read the legacy state's `region` field. Used for region matching during
|
|
2990
|
+
* `stateExists` / `deleteState` and for assigning a region to legacy
|
|
2991
|
+
* entries during `listStacks`.
|
|
2992
|
+
*/
|
|
2993
|
+
async readLegacyRegion(stackName) {
|
|
2994
|
+
try {
|
|
2834
2995
|
const response = await this.s3Client.send(
|
|
2835
|
-
new
|
|
2996
|
+
new GetObjectCommand({
|
|
2836
2997
|
Bucket: this.config.bucket,
|
|
2837
|
-
|
|
2838
|
-
Delimiter: "/"
|
|
2998
|
+
Key: this.getLegacyStateKey(stackName)
|
|
2839
2999
|
})
|
|
2840
3000
|
);
|
|
2841
|
-
if (!response.
|
|
2842
|
-
return
|
|
2843
|
-
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
const parts = prefixStr.split("/");
|
|
2847
|
-
return parts[parts.length - 2];
|
|
2848
|
-
}).filter((name) => Boolean(name));
|
|
2849
|
-
this.logger.debug(`Found ${stackNames.length} stacks`);
|
|
2850
|
-
return stackNames;
|
|
3001
|
+
if (!response.Body)
|
|
3002
|
+
return void 0;
|
|
3003
|
+
const bodyString = await response.Body.transformToString();
|
|
3004
|
+
const state = JSON.parse(bodyString);
|
|
3005
|
+
return typeof state.region === "string" ? state.region : void 0;
|
|
2851
3006
|
} catch (error) {
|
|
3007
|
+
if (isNoSuchKey(error))
|
|
3008
|
+
return void 0;
|
|
3009
|
+
this.logger.debug(
|
|
3010
|
+
`Could not read legacy state region for '${stackName}': ${error instanceof Error ? error.message : String(error)}`
|
|
3011
|
+
);
|
|
3012
|
+
return void 0;
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
async legacyMatchesRegion(stackName, region) {
|
|
3016
|
+
const legacyRegion = await this.readLegacyRegion(stackName);
|
|
3017
|
+
return legacyRegion === region;
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* Try to read the legacy `version: 1` state. Returns null when the legacy
|
|
3021
|
+
* key is missing or its embedded region does not match the caller's region.
|
|
3022
|
+
*/
|
|
3023
|
+
async tryGetLegacy(stackName, region) {
|
|
3024
|
+
try {
|
|
3025
|
+
const response = await this.s3Client.send(
|
|
3026
|
+
new GetObjectCommand({
|
|
3027
|
+
Bucket: this.config.bucket,
|
|
3028
|
+
Key: this.getLegacyStateKey(stackName)
|
|
3029
|
+
})
|
|
3030
|
+
);
|
|
3031
|
+
if (!response.Body || !response.ETag) {
|
|
3032
|
+
return null;
|
|
3033
|
+
}
|
|
3034
|
+
const bodyString = await response.Body.transformToString();
|
|
3035
|
+
const state = this.parseStateBody(bodyString, stackName);
|
|
3036
|
+
if (state.region && state.region !== region) {
|
|
3037
|
+
this.logger.debug(
|
|
3038
|
+
`Legacy state for stack '${stackName}' has region '${state.region}', not '${region}' \u2014 skipping legacy fallback.`
|
|
3039
|
+
);
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
return { state, etag: response.ETag };
|
|
3043
|
+
} catch (error) {
|
|
3044
|
+
if (isNoSuchKey(error))
|
|
3045
|
+
return null;
|
|
2852
3046
|
throw new StateError(
|
|
2853
|
-
`Failed to
|
|
3047
|
+
`Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
2854
3048
|
error instanceof Error ? error : void 0
|
|
2855
3049
|
);
|
|
2856
3050
|
}
|
|
2857
3051
|
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Parse a state body and validate the schema version. Future-proofs against
|
|
3054
|
+
* a binary that predates schema version `N` reading a `version: N+1` blob:
|
|
3055
|
+
* the old binary would otherwise treat unknown fields as defaults and
|
|
3056
|
+
* silently lose data on the next save.
|
|
3057
|
+
*/
|
|
3058
|
+
parseStateBody(bodyString, stackName) {
|
|
3059
|
+
let parsed;
|
|
3060
|
+
try {
|
|
3061
|
+
parsed = JSON.parse(bodyString);
|
|
3062
|
+
} catch (error) {
|
|
3063
|
+
throw new StateError(
|
|
3064
|
+
`State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
3065
|
+
error instanceof Error ? error : void 0
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
const v = parsed.version;
|
|
3069
|
+
if (v !== STATE_SCHEMA_VERSION_LEGACY && v !== STATE_SCHEMA_VERSION_CURRENT && v !== void 0) {
|
|
3070
|
+
throw new StateError(
|
|
3071
|
+
`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)}.`
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
return parsed;
|
|
3075
|
+
}
|
|
2858
3076
|
};
|
|
3077
|
+
function isNoSuchKey(error) {
|
|
3078
|
+
if (error instanceof NoSuchKey)
|
|
3079
|
+
return true;
|
|
3080
|
+
const name = error?.name;
|
|
3081
|
+
return name === "NoSuchKey";
|
|
3082
|
+
}
|
|
2859
3083
|
|
|
2860
3084
|
// src/state/lock-manager.ts
|
|
2861
3085
|
import {
|
|
@@ -2876,10 +3100,24 @@ var LockManager = class {
|
|
|
2876
3100
|
logger = getLogger().child("LockManager");
|
|
2877
3101
|
ttlMs;
|
|
2878
3102
|
/**
|
|
2879
|
-
* Get the S3 key for a stack's lock file
|
|
3103
|
+
* Get the S3 key for a stack's lock file.
|
|
3104
|
+
*
|
|
3105
|
+
* Locks are region-scoped, mirroring the state key layout
|
|
3106
|
+
* (`{prefix}/{stackName}/{region}/lock.json`). Two regions of the same
|
|
3107
|
+
* stackName can therefore be operated on in parallel without contention,
|
|
3108
|
+
* matching cdkd's parallel execution model.
|
|
3109
|
+
*
|
|
3110
|
+
* The `region` argument is required for new callers; for backwards
|
|
3111
|
+
* compatibility with `state list --long` (which only sees stack names),
|
|
3112
|
+
* passing `undefined` falls back to the legacy `{prefix}/{stackName}/lock.json`
|
|
3113
|
+
* key — that mode is purely for legacy lock cleanup and is NOT used by
|
|
3114
|
+
* deploy / destroy / diff anymore.
|
|
2880
3115
|
*/
|
|
2881
|
-
getLockKey(stackName) {
|
|
2882
|
-
|
|
3116
|
+
getLockKey(stackName, region) {
|
|
3117
|
+
if (region === void 0) {
|
|
3118
|
+
return `${this.config.prefix}/${stackName}/lock.json`;
|
|
3119
|
+
}
|
|
3120
|
+
return `${this.config.prefix}/${stackName}/${region}/lock.json`;
|
|
2883
3121
|
}
|
|
2884
3122
|
/**
|
|
2885
3123
|
* Get default lock owner identifier
|
|
@@ -2918,11 +3156,12 @@ var LockManager = class {
|
|
|
2918
3156
|
* If an expired lock exists, it will be cleaned up and re-acquired.
|
|
2919
3157
|
*
|
|
2920
3158
|
* @param stackName Stack name
|
|
3159
|
+
* @param region Target region (lock key is region-scoped)
|
|
2921
3160
|
* @param owner Lock owner identifier (defaults to user@hostname:pid)
|
|
2922
3161
|
* @param operation Operation being performed (e.g., "deploy", "destroy")
|
|
2923
3162
|
*/
|
|
2924
|
-
async acquireLock(stackName, owner, operation) {
|
|
2925
|
-
const key = this.getLockKey(stackName);
|
|
3163
|
+
async acquireLock(stackName, region, owner, operation) {
|
|
3164
|
+
const key = this.getLockKey(stackName, region);
|
|
2926
3165
|
const lockOwner = owner || this.getDefaultOwner();
|
|
2927
3166
|
const now = Date.now();
|
|
2928
3167
|
const lockInfo = {
|
|
@@ -2932,7 +3171,7 @@ var LockManager = class {
|
|
|
2932
3171
|
...operation && { operation }
|
|
2933
3172
|
};
|
|
2934
3173
|
try {
|
|
2935
|
-
this.logger.debug(`Attempting to acquire lock for stack: ${stackName}`);
|
|
3174
|
+
this.logger.debug(`Attempting to acquire lock for stack: ${stackName} (${region})`);
|
|
2936
3175
|
const lockBody = JSON.stringify(lockInfo, null, 2);
|
|
2937
3176
|
await this.s3Client.send(
|
|
2938
3177
|
new PutObjectCommand3({
|
|
@@ -2945,17 +3184,17 @@ var LockManager = class {
|
|
|
2945
3184
|
// Only succeed if object doesn't exist
|
|
2946
3185
|
})
|
|
2947
3186
|
);
|
|
2948
|
-
this.logger.debug(`Lock acquired for stack: ${stackName}, owner: ${lockOwner}`);
|
|
3187
|
+
this.logger.debug(`Lock acquired for stack: ${stackName} (${region}), owner: ${lockOwner}`);
|
|
2949
3188
|
return true;
|
|
2950
3189
|
} catch (error) {
|
|
2951
3190
|
if (error instanceof S3ServiceException && error.name === "PreconditionFailed") {
|
|
2952
|
-
this.logger.debug(`Lock already exists for stack: ${stackName}`);
|
|
2953
|
-
const existingLock = await this.getLockInfo(stackName);
|
|
3191
|
+
this.logger.debug(`Lock already exists for stack: ${stackName} (${region})`);
|
|
3192
|
+
const existingLock = await this.getLockInfo(stackName, region);
|
|
2954
3193
|
if (existingLock && this.isLockExpired(existingLock)) {
|
|
2955
3194
|
this.logger.info(
|
|
2956
|
-
`Expired lock detected for stack: ${stackName} (owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
|
|
3195
|
+
`Expired lock detected for stack: ${stackName} (${region}, owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
|
|
2957
3196
|
);
|
|
2958
|
-
await this.deleteLock(stackName);
|
|
3197
|
+
await this.deleteLock(stackName, region);
|
|
2959
3198
|
try {
|
|
2960
3199
|
const retryBody = JSON.stringify(lockInfo, null, 2);
|
|
2961
3200
|
await this.s3Client.send(
|
|
@@ -2969,13 +3208,13 @@ var LockManager = class {
|
|
|
2969
3208
|
})
|
|
2970
3209
|
);
|
|
2971
3210
|
this.logger.debug(
|
|
2972
|
-
`Lock acquired for stack: ${stackName} after expired lock cleanup, owner: ${lockOwner}`
|
|
3211
|
+
`Lock acquired for stack: ${stackName} (${region}) after expired lock cleanup, owner: ${lockOwner}`
|
|
2973
3212
|
);
|
|
2974
3213
|
return true;
|
|
2975
3214
|
} catch (retryError) {
|
|
2976
3215
|
if (retryError instanceof S3ServiceException && retryError.name === "PreconditionFailed") {
|
|
2977
3216
|
this.logger.debug(
|
|
2978
|
-
`Lock was acquired by another process during expired lock cleanup for stack: ${stackName}`
|
|
3217
|
+
`Lock was acquired by another process during expired lock cleanup for stack: ${stackName} (${region})`
|
|
2979
3218
|
);
|
|
2980
3219
|
return false;
|
|
2981
3220
|
}
|
|
@@ -2985,16 +3224,20 @@ var LockManager = class {
|
|
|
2985
3224
|
return false;
|
|
2986
3225
|
}
|
|
2987
3226
|
throw new LockError(
|
|
2988
|
-
`Failed to acquire lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3227
|
+
`Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
2989
3228
|
error instanceof Error ? error : void 0
|
|
2990
3229
|
);
|
|
2991
3230
|
}
|
|
2992
3231
|
}
|
|
2993
3232
|
/**
|
|
2994
|
-
* Get current lock information
|
|
3233
|
+
* Get current lock information.
|
|
3234
|
+
*
|
|
3235
|
+
* `region` is required for the new region-scoped lock layout. Pass
|
|
3236
|
+
* `undefined` only to inspect a legacy `{prefix}/{stackName}/lock.json`
|
|
3237
|
+
* file (e.g. for state-listing tools that don't yet know the region).
|
|
2995
3238
|
*/
|
|
2996
|
-
async getLockInfo(stackName) {
|
|
2997
|
-
const key = this.getLockKey(stackName);
|
|
3239
|
+
async getLockInfo(stackName, region) {
|
|
3240
|
+
const key = this.getLockKey(stackName, region);
|
|
2998
3241
|
try {
|
|
2999
3242
|
this.logger.debug(`Getting lock info for stack: ${stackName}`);
|
|
3000
3243
|
const response = await this.s3Client.send(
|
|
@@ -3032,27 +3275,27 @@ var LockManager = class {
|
|
|
3032
3275
|
* not for acquisition decisions — use `acquireLock` for that, which has its
|
|
3033
3276
|
* own expired-lock cleanup logic.
|
|
3034
3277
|
*/
|
|
3035
|
-
async isLocked(stackName) {
|
|
3036
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3278
|
+
async isLocked(stackName, region) {
|
|
3279
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3037
3280
|
return lockInfo !== null;
|
|
3038
3281
|
}
|
|
3039
3282
|
/**
|
|
3040
3283
|
* Release a lock for a stack
|
|
3041
3284
|
*/
|
|
3042
|
-
async releaseLock(stackName) {
|
|
3043
|
-
const key = this.getLockKey(stackName);
|
|
3285
|
+
async releaseLock(stackName, region) {
|
|
3286
|
+
const key = this.getLockKey(stackName, region);
|
|
3044
3287
|
try {
|
|
3045
|
-
this.logger.debug(`Releasing lock for stack: ${stackName}`);
|
|
3288
|
+
this.logger.debug(`Releasing lock for stack: ${stackName} (${region})`);
|
|
3046
3289
|
await this.s3Client.send(
|
|
3047
3290
|
new DeleteObjectCommand2({
|
|
3048
3291
|
Bucket: this.config.bucket,
|
|
3049
3292
|
Key: key
|
|
3050
3293
|
})
|
|
3051
3294
|
);
|
|
3052
|
-
this.logger.debug(`Lock released for stack: ${stackName}`);
|
|
3295
|
+
this.logger.debug(`Lock released for stack: ${stackName} (${region})`);
|
|
3053
3296
|
} catch (error) {
|
|
3054
3297
|
throw new LockError(
|
|
3055
|
-
`Failed to release lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3298
|
+
`Failed to release lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
|
|
3056
3299
|
error instanceof Error ? error : void 0
|
|
3057
3300
|
);
|
|
3058
3301
|
}
|
|
@@ -3062,23 +3305,28 @@ var LockManager = class {
|
|
|
3062
3305
|
*
|
|
3063
3306
|
* This is intended for CLI usage (e.g., --force-unlock flag) when a lock
|
|
3064
3307
|
* is stuck and needs manual intervention.
|
|
3308
|
+
*
|
|
3309
|
+
* Pass `region: undefined` to operate on a legacy
|
|
3310
|
+
* `{prefix}/{stackName}/lock.json` file.
|
|
3065
3311
|
*/
|
|
3066
|
-
async forceReleaseLock(stackName) {
|
|
3067
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3312
|
+
async forceReleaseLock(stackName, region) {
|
|
3313
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3068
3314
|
if (!lockInfo) {
|
|
3069
|
-
this.logger.warn(
|
|
3315
|
+
this.logger.warn(
|
|
3316
|
+
`No lock to force release for stack: ${stackName}${region ? ` (${region})` : ""}`
|
|
3317
|
+
);
|
|
3070
3318
|
return;
|
|
3071
3319
|
}
|
|
3072
3320
|
this.logger.warn(
|
|
3073
|
-
`Force releasing lock for stack: ${stackName}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
|
|
3321
|
+
`Force releasing lock for stack: ${stackName}${region ? ` (${region})` : ""}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
|
|
3074
3322
|
);
|
|
3075
|
-
await this.deleteLock(stackName);
|
|
3323
|
+
await this.deleteLock(stackName, region);
|
|
3076
3324
|
}
|
|
3077
3325
|
/**
|
|
3078
3326
|
* Internal method to delete the lock file from S3
|
|
3079
3327
|
*/
|
|
3080
|
-
async deleteLock(stackName) {
|
|
3081
|
-
const key = this.getLockKey(stackName);
|
|
3328
|
+
async deleteLock(stackName, region) {
|
|
3329
|
+
const key = this.getLockKey(stackName, region);
|
|
3082
3330
|
await this.s3Client.send(
|
|
3083
3331
|
new DeleteObjectCommand2({
|
|
3084
3332
|
Bucket: this.config.bucket,
|
|
@@ -3099,28 +3347,28 @@ var LockManager = class {
|
|
|
3099
3347
|
* @param maxRetries Maximum number of retries (default: 3)
|
|
3100
3348
|
* @param retryDelay Delay between retries in milliseconds (default: 2000)
|
|
3101
3349
|
*/
|
|
3102
|
-
async acquireLockWithRetry(stackName, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3350
|
+
async acquireLockWithRetry(stackName, region, owner, operation, maxRetries = 3, retryDelay = 2e3) {
|
|
3103
3351
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3104
|
-
const acquired = await this.acquireLock(stackName, owner, operation);
|
|
3352
|
+
const acquired = await this.acquireLock(stackName, region, owner, operation);
|
|
3105
3353
|
if (acquired) {
|
|
3106
3354
|
return;
|
|
3107
3355
|
}
|
|
3108
|
-
const lockInfo2 = await this.getLockInfo(stackName);
|
|
3356
|
+
const lockInfo2 = await this.getLockInfo(stackName, region);
|
|
3109
3357
|
if (lockInfo2) {
|
|
3110
3358
|
const remainingMs = lockInfo2.expiresAt - Date.now();
|
|
3111
3359
|
if (attempt < maxRetries) {
|
|
3112
3360
|
this.logger.info(
|
|
3113
|
-
`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})`
|
|
3361
|
+
`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})`
|
|
3114
3362
|
);
|
|
3115
3363
|
await new Promise((resolve4) => setTimeout(resolve4, retryDelay));
|
|
3116
3364
|
continue;
|
|
3117
3365
|
}
|
|
3118
3366
|
}
|
|
3119
3367
|
}
|
|
3120
|
-
const lockInfo = await this.getLockInfo(stackName);
|
|
3368
|
+
const lockInfo = await this.getLockInfo(stackName, region);
|
|
3121
3369
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
3122
3370
|
throw new LockError(
|
|
3123
|
-
`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.")
|
|
3371
|
+
`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.")
|
|
3124
3372
|
);
|
|
3125
3373
|
}
|
|
3126
3374
|
};
|
|
@@ -4903,35 +5151,45 @@ var IntrinsicFunctionResolver = class {
|
|
|
4903
5151
|
}
|
|
4904
5152
|
this.logger.debug(`Resolving Fn::ImportValue: ${exportName}`);
|
|
4905
5153
|
const allStacks = await context.stateBackend.listStacks();
|
|
4906
|
-
this.logger.debug(
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
5154
|
+
this.logger.debug(
|
|
5155
|
+
`Found ${allStacks.length} state record(s) to search for export: ${exportName}`
|
|
5156
|
+
);
|
|
5157
|
+
for (const ref of allStacks) {
|
|
5158
|
+
const { stackName: refStack, region: refRegion } = ref;
|
|
5159
|
+
if (context.stackName && refStack === context.stackName) {
|
|
5160
|
+
this.logger.debug(`Skipping current stack: ${refStack}`);
|
|
4910
5161
|
continue;
|
|
4911
5162
|
}
|
|
4912
5163
|
try {
|
|
4913
|
-
const
|
|
5164
|
+
const lookupRegion = refRegion ?? this.resolverRegion ?? "";
|
|
5165
|
+
if (!lookupRegion) {
|
|
5166
|
+
this.logger.debug(
|
|
5167
|
+
`No region available for stack '${refStack}' \u2014 skipping (cdkd cannot read state without a region)`
|
|
5168
|
+
);
|
|
5169
|
+
continue;
|
|
5170
|
+
}
|
|
5171
|
+
const stateData = await context.stateBackend.getState(refStack, lookupRegion);
|
|
4914
5172
|
if (!stateData) {
|
|
4915
|
-
this.logger.debug(`No state found for stack: ${
|
|
5173
|
+
this.logger.debug(`No state found for stack: ${refStack} (${lookupRegion})`);
|
|
4916
5174
|
continue;
|
|
4917
5175
|
}
|
|
4918
5176
|
const { state } = stateData;
|
|
4919
5177
|
if (state.outputs && exportName in state.outputs) {
|
|
4920
5178
|
const value = state.outputs[exportName];
|
|
4921
5179
|
this.logger.info(
|
|
4922
|
-
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${
|
|
5180
|
+
`Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${refStack} / ${lookupRegion})`
|
|
4923
5181
|
);
|
|
4924
5182
|
return value;
|
|
4925
5183
|
}
|
|
4926
5184
|
} catch (error) {
|
|
4927
5185
|
this.logger.warn(
|
|
4928
|
-
`Failed to read state for stack ${
|
|
5186
|
+
`Failed to read state for stack ${refStack}: ${error instanceof Error ? error.message : String(error)}`
|
|
4929
5187
|
);
|
|
4930
5188
|
continue;
|
|
4931
5189
|
}
|
|
4932
5190
|
}
|
|
4933
5191
|
throw new Error(
|
|
4934
|
-
`Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length}
|
|
5192
|
+
`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.`
|
|
4935
5193
|
);
|
|
4936
5194
|
}
|
|
4937
5195
|
/**
|
|
@@ -5394,6 +5652,29 @@ var JsonPatchGenerator = class {
|
|
|
5394
5652
|
}
|
|
5395
5653
|
};
|
|
5396
5654
|
|
|
5655
|
+
// src/provisioning/region-check.ts
|
|
5656
|
+
function assertRegionMatch(clientRegion, expectedRegion, resourceType, logicalId, physicalId) {
|
|
5657
|
+
if (!expectedRegion) {
|
|
5658
|
+
return;
|
|
5659
|
+
}
|
|
5660
|
+
if (!clientRegion) {
|
|
5661
|
+
throw new ProvisioningError(
|
|
5662
|
+
`Refusing to treat NotFound as idempotent delete success for ${logicalId} (${resourceType}): AWS client region is unknown but stack state expects ${expectedRegion}. The resource may exist in ${expectedRegion} and would be silently removed from state if this NotFound were trusted.`,
|
|
5663
|
+
resourceType,
|
|
5664
|
+
logicalId,
|
|
5665
|
+
physicalId
|
|
5666
|
+
);
|
|
5667
|
+
}
|
|
5668
|
+
if (clientRegion !== expectedRegion) {
|
|
5669
|
+
throw new ProvisioningError(
|
|
5670
|
+
`Refusing to treat NotFound as idempotent delete success for ${logicalId} (${resourceType}): AWS client region ${clientRegion} does not match stack state region ${expectedRegion}. The resource likely still exists in ${expectedRegion}; rerun the destroy with the correct region (e.g. --region ${expectedRegion}).`,
|
|
5671
|
+
resourceType,
|
|
5672
|
+
logicalId,
|
|
5673
|
+
physicalId
|
|
5674
|
+
);
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
|
|
5397
5678
|
// src/provisioning/cloud-control-provider.ts
|
|
5398
5679
|
var JSON_STRING_PROPERTIES = {
|
|
5399
5680
|
"AWS::Events::Rule": /* @__PURE__ */ new Set(["EventPattern"])
|
|
@@ -5569,7 +5850,7 @@ var CloudControlProvider = class {
|
|
|
5569
5850
|
/**
|
|
5570
5851
|
* Delete a resource using Cloud Control API
|
|
5571
5852
|
*/
|
|
5572
|
-
async delete(logicalId, physicalId, resourceType, _properties) {
|
|
5853
|
+
async delete(logicalId, physicalId, resourceType, _properties, context) {
|
|
5573
5854
|
this.logger.debug(
|
|
5574
5855
|
`Deleting resource ${logicalId} (${resourceType}), physical ID: ${physicalId}`
|
|
5575
5856
|
);
|
|
@@ -5596,6 +5877,14 @@ var CloudControlProvider = class {
|
|
|
5596
5877
|
} catch (error) {
|
|
5597
5878
|
const err = error;
|
|
5598
5879
|
if (err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound")) {
|
|
5880
|
+
const clientRegion = await this.cloudControlClient.config.region();
|
|
5881
|
+
assertRegionMatch(
|
|
5882
|
+
clientRegion,
|
|
5883
|
+
context?.expectedRegion,
|
|
5884
|
+
resourceType,
|
|
5885
|
+
logicalId,
|
|
5886
|
+
physicalId
|
|
5887
|
+
);
|
|
5599
5888
|
this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
|
|
5600
5889
|
return;
|
|
5601
5890
|
}
|
|
@@ -6160,7 +6449,7 @@ var CustomResourceProvider = class _CustomResourceProvider {
|
|
|
6160
6449
|
/**
|
|
6161
6450
|
* Delete a custom resource by invoking its Lambda handler
|
|
6162
6451
|
*/
|
|
6163
|
-
async delete(logicalId, physicalId, resourceType, properties) {
|
|
6452
|
+
async delete(logicalId, physicalId, resourceType, properties, _context) {
|
|
6164
6453
|
this.logger.debug(`Deleting custom resource ${logicalId}: ${physicalId} (${resourceType})`);
|
|
6165
6454
|
if (!properties) {
|
|
6166
6455
|
this.logger.warn(
|
|
@@ -6982,13 +7271,21 @@ var IAMRoleProvider = class {
|
|
|
6982
7271
|
* 3. Remove role from all instance profiles
|
|
6983
7272
|
* 4. Delete the role itself
|
|
6984
7273
|
*/
|
|
6985
|
-
async delete(logicalId, physicalId, resourceType, _properties) {
|
|
7274
|
+
async delete(logicalId, physicalId, resourceType, _properties, context) {
|
|
6986
7275
|
this.logger.debug(`Deleting IAM role ${logicalId}: ${physicalId}`);
|
|
6987
7276
|
try {
|
|
6988
7277
|
try {
|
|
6989
7278
|
await this.iamClient.send(new GetRoleCommand({ RoleName: physicalId }));
|
|
6990
7279
|
} catch (error) {
|
|
6991
7280
|
if (error instanceof NoSuchEntityException) {
|
|
7281
|
+
const clientRegion = await this.iamClient.config.region();
|
|
7282
|
+
assertRegionMatch(
|
|
7283
|
+
clientRegion,
|
|
7284
|
+
context?.expectedRegion,
|
|
7285
|
+
resourceType,
|
|
7286
|
+
logicalId,
|
|
7287
|
+
physicalId
|
|
7288
|
+
);
|
|
6992
7289
|
this.logger.debug(`Role ${physicalId} does not exist, skipping deletion`);
|
|
6993
7290
|
return;
|
|
6994
7291
|
}
|
|
@@ -7478,7 +7775,11 @@ var DeployEngine = class {
|
|
|
7478
7775
|
logger = getLogger().child("DeployEngine");
|
|
7479
7776
|
resolver;
|
|
7480
7777
|
interrupted = false;
|
|
7481
|
-
/**
|
|
7778
|
+
/**
|
|
7779
|
+
* Target region for this stack. Required — load-bearing for the
|
|
7780
|
+
* region-prefixed S3 state key and recorded in state.json for
|
|
7781
|
+
* cross-region destroy.
|
|
7782
|
+
*/
|
|
7482
7783
|
stackRegion;
|
|
7483
7784
|
/**
|
|
7484
7785
|
* Deploy a CloudFormation template
|
|
@@ -7487,7 +7788,7 @@ var DeployEngine = class {
|
|
|
7487
7788
|
const startTime = Date.now();
|
|
7488
7789
|
this.logger.debug(`Starting deployment for stack: ${stackName}`);
|
|
7489
7790
|
setCurrentStackName(stackName);
|
|
7490
|
-
await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
|
|
7791
|
+
await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
|
|
7491
7792
|
const renderer = getLiveRenderer();
|
|
7492
7793
|
renderer.start();
|
|
7493
7794
|
this.interrupted = false;
|
|
@@ -7501,16 +7802,17 @@ var DeployEngine = class {
|
|
|
7501
7802
|
};
|
|
7502
7803
|
process.on("SIGINT", sigintHandler);
|
|
7503
7804
|
try {
|
|
7504
|
-
const currentStateData = await this.stateBackend.getState(stackName);
|
|
7805
|
+
const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
7505
7806
|
const currentState = currentStateData?.state ?? {
|
|
7506
|
-
version:
|
|
7507
|
-
|
|
7807
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
7808
|
+
region: this.stackRegion,
|
|
7508
7809
|
stackName,
|
|
7509
7810
|
resources: {},
|
|
7510
7811
|
outputs: {},
|
|
7511
7812
|
lastModified: Date.now()
|
|
7512
7813
|
};
|
|
7513
7814
|
const currentEtag = currentStateData?.etag;
|
|
7815
|
+
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
7514
7816
|
this.logger.debug(
|
|
7515
7817
|
`Loaded current state: ${Object.keys(currentState.resources).length} resources`
|
|
7516
7818
|
);
|
|
@@ -7596,9 +7898,10 @@ var DeployEngine = class {
|
|
|
7596
7898
|
parameterValues,
|
|
7597
7899
|
conditions,
|
|
7598
7900
|
currentEtag,
|
|
7599
|
-
progress
|
|
7901
|
+
progress,
|
|
7902
|
+
migrationPending
|
|
7600
7903
|
);
|
|
7601
|
-
const newEtag = await this.stateBackend.saveState(stackName, newState);
|
|
7904
|
+
const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
|
|
7602
7905
|
this.logger.debug(`State saved (ETag: ${newEtag})`);
|
|
7603
7906
|
const durationMs = Date.now() - startTime;
|
|
7604
7907
|
const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
|
|
@@ -7614,7 +7917,7 @@ var DeployEngine = class {
|
|
|
7614
7917
|
renderer.stop();
|
|
7615
7918
|
process.removeListener("SIGINT", sigintHandler);
|
|
7616
7919
|
try {
|
|
7617
|
-
await this.lockManager.releaseLock(stackName);
|
|
7920
|
+
await this.lockManager.releaseLock(stackName, this.stackRegion);
|
|
7618
7921
|
this.logger.debug("Lock released");
|
|
7619
7922
|
} catch (lockError) {
|
|
7620
7923
|
this.logger.warn(
|
|
@@ -7632,11 +7935,12 @@ var DeployEngine = class {
|
|
|
7632
7935
|
* - DELETE follows reverse dependency order (a node starts as soon as all
|
|
7633
7936
|
* resources that depend ON it have finished deleting)
|
|
7634
7937
|
*/
|
|
7635
|
-
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
|
|
7938
|
+
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
|
|
7636
7939
|
const concurrency = this.options.concurrency;
|
|
7637
7940
|
const newResources = { ...currentState.resources };
|
|
7638
7941
|
const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
|
|
7639
7942
|
const completedOperations = [];
|
|
7943
|
+
let pendingMigration = migrationPending;
|
|
7640
7944
|
let saveChain = Promise.resolve();
|
|
7641
7945
|
const saveStateAfterResource = (logicalId) => {
|
|
7642
7946
|
if (currentEtag === void 0)
|
|
@@ -7644,14 +7948,23 @@ var DeployEngine = class {
|
|
|
7644
7948
|
saveChain = saveChain.then(async () => {
|
|
7645
7949
|
try {
|
|
7646
7950
|
const partialState = {
|
|
7647
|
-
version:
|
|
7648
|
-
|
|
7951
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
7952
|
+
region: this.stackRegion,
|
|
7649
7953
|
stackName: currentState.stackName,
|
|
7650
7954
|
resources: newResources,
|
|
7651
7955
|
outputs: currentState.outputs,
|
|
7652
7956
|
lastModified: Date.now()
|
|
7653
7957
|
};
|
|
7654
|
-
|
|
7958
|
+
const migrate = pendingMigration;
|
|
7959
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
7960
|
+
currentEtag = await this.stateBackend.saveState(
|
|
7961
|
+
stackName,
|
|
7962
|
+
this.stackRegion,
|
|
7963
|
+
partialState,
|
|
7964
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
7965
|
+
);
|
|
7966
|
+
if (migrate)
|
|
7967
|
+
pendingMigration = false;
|
|
7655
7968
|
this.logger.debug(`State saved after ${logicalId}`);
|
|
7656
7969
|
} catch (error) {
|
|
7657
7970
|
this.logger.warn(
|
|
@@ -7785,14 +8098,23 @@ var DeployEngine = class {
|
|
|
7785
8098
|
} catch (error) {
|
|
7786
8099
|
try {
|
|
7787
8100
|
const preRollbackState = {
|
|
7788
|
-
version:
|
|
7789
|
-
|
|
8101
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8102
|
+
region: this.stackRegion,
|
|
7790
8103
|
stackName: currentState.stackName,
|
|
7791
8104
|
resources: newResources,
|
|
7792
8105
|
outputs: currentState.outputs,
|
|
7793
8106
|
lastModified: Date.now()
|
|
7794
8107
|
};
|
|
7795
|
-
|
|
8108
|
+
const migrate = pendingMigration;
|
|
8109
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
8110
|
+
currentEtag = await this.stateBackend.saveState(
|
|
8111
|
+
stackName,
|
|
8112
|
+
this.stackRegion,
|
|
8113
|
+
preRollbackState,
|
|
8114
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
8115
|
+
);
|
|
8116
|
+
if (migrate)
|
|
8117
|
+
pendingMigration = false;
|
|
7796
8118
|
this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
|
|
7797
8119
|
} catch (saveError) {
|
|
7798
8120
|
this.logger.warn(
|
|
@@ -7813,31 +8135,35 @@ var DeployEngine = class {
|
|
|
7813
8135
|
}
|
|
7814
8136
|
try {
|
|
7815
8137
|
const postRollbackState = {
|
|
7816
|
-
version:
|
|
7817
|
-
|
|
8138
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8139
|
+
region: this.stackRegion,
|
|
7818
8140
|
stackName: currentState.stackName,
|
|
7819
8141
|
resources: newResources,
|
|
7820
8142
|
outputs: currentState.outputs,
|
|
7821
8143
|
lastModified: Date.now()
|
|
7822
8144
|
};
|
|
7823
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
8145
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
8146
|
+
...currentEtag !== void 0 && { expectedEtag: currentEtag }
|
|
8147
|
+
});
|
|
7824
8148
|
this.logger.debug("State saved after deployment failure");
|
|
7825
8149
|
} catch (saveError) {
|
|
7826
8150
|
this.logger.debug(
|
|
7827
8151
|
`Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
|
|
7828
8152
|
);
|
|
7829
8153
|
try {
|
|
7830
|
-
const freshState = await this.stateBackend.getState(stackName);
|
|
8154
|
+
const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
7831
8155
|
const freshEtag = freshState?.etag;
|
|
7832
8156
|
const postRollbackState = {
|
|
7833
|
-
version:
|
|
7834
|
-
|
|
8157
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8158
|
+
region: this.stackRegion,
|
|
7835
8159
|
stackName: currentState.stackName,
|
|
7836
8160
|
resources: newResources,
|
|
7837
8161
|
outputs: currentState.outputs,
|
|
7838
8162
|
lastModified: Date.now()
|
|
7839
8163
|
};
|
|
7840
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
8164
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
8165
|
+
...freshEtag !== void 0 && { expectedEtag: freshEtag }
|
|
8166
|
+
});
|
|
7841
8167
|
this.logger.debug("State saved after deployment failure (retry succeeded)");
|
|
7842
8168
|
} catch (retryError) {
|
|
7843
8169
|
this.logger.warn(
|
|
@@ -7856,8 +8182,8 @@ var DeployEngine = class {
|
|
|
7856
8182
|
);
|
|
7857
8183
|
return {
|
|
7858
8184
|
state: {
|
|
7859
|
-
version:
|
|
7860
|
-
|
|
8185
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8186
|
+
region: this.stackRegion,
|
|
7861
8187
|
stackName: currentState.stackName,
|
|
7862
8188
|
resources: newResources,
|
|
7863
8189
|
outputs,
|
|
@@ -7986,7 +8312,9 @@ var DeployEngine = class {
|
|
|
7986
8312
|
` Rollback: Deleting created resource ${op.logicalId} (${op.resourceType})`
|
|
7987
8313
|
);
|
|
7988
8314
|
const provider = this.providerRegistry.getProvider(op.resourceType);
|
|
7989
|
-
await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties
|
|
8315
|
+
await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties, {
|
|
8316
|
+
expectedRegion: this.stackRegion
|
|
8317
|
+
});
|
|
7990
8318
|
delete stateResources[op.logicalId];
|
|
7991
8319
|
this.logger.info(` Rollback: ${op.logicalId} deleted successfully`);
|
|
7992
8320
|
break;
|
|
@@ -8128,7 +8456,8 @@ var DeployEngine = class {
|
|
|
8128
8456
|
logicalId,
|
|
8129
8457
|
currentResource.physicalId,
|
|
8130
8458
|
resourceType,
|
|
8131
|
-
currentResource.properties
|
|
8459
|
+
currentResource.properties,
|
|
8460
|
+
{ expectedRegion: this.stackRegion }
|
|
8132
8461
|
);
|
|
8133
8462
|
this.logger.info(` \u2713 Old resource deleted`);
|
|
8134
8463
|
} catch (deleteError) {
|
|
@@ -8177,7 +8506,8 @@ var DeployEngine = class {
|
|
|
8177
8506
|
logicalId,
|
|
8178
8507
|
currentResource.physicalId,
|
|
8179
8508
|
resourceType,
|
|
8180
|
-
currentProps
|
|
8509
|
+
currentProps,
|
|
8510
|
+
{ expectedRegion: this.stackRegion }
|
|
8181
8511
|
);
|
|
8182
8512
|
} catch (deleteError) {
|
|
8183
8513
|
const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
|
@@ -8248,7 +8578,8 @@ var DeployEngine = class {
|
|
|
8248
8578
|
logicalId,
|
|
8249
8579
|
currentResource.physicalId,
|
|
8250
8580
|
resourceType,
|
|
8251
|
-
currentResource.properties
|
|
8581
|
+
currentResource.properties,
|
|
8582
|
+
{ expectedRegion: this.stackRegion }
|
|
8252
8583
|
),
|
|
8253
8584
|
logicalId,
|
|
8254
8585
|
3,
|