@go-to-k/cdkd 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -6
- package/dist/cli.js +658 -221
- package/dist/cli.js.map +4 -4
- package/dist/go-to-k-cdkd-0.8.0.tgz +0 -0
- package/dist/index.js +440 -153
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/dist/go-to-k-cdkd-0.6.0.tgz +0 -0
package/dist/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
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
this.logger.debug(
|
|
2850
|
-
|
|
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;
|
|
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;
|
|
3046
|
+
throw new StateError(
|
|
3047
|
+
`Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
|
|
3048
|
+
error instanceof Error ? error : void 0
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
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);
|
|
2851
3062
|
} catch (error) {
|
|
2852
3063
|
throw new StateError(
|
|
2853
|
-
`
|
|
3064
|
+
`State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2854
3065
|
error instanceof Error ? error : void 0
|
|
2855
3066
|
);
|
|
2856
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;
|
|
2857
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
|
/**
|
|
@@ -7478,7 +7736,11 @@ var DeployEngine = class {
|
|
|
7478
7736
|
logger = getLogger().child("DeployEngine");
|
|
7479
7737
|
resolver;
|
|
7480
7738
|
interrupted = false;
|
|
7481
|
-
/**
|
|
7739
|
+
/**
|
|
7740
|
+
* Target region for this stack. Required — load-bearing for the
|
|
7741
|
+
* region-prefixed S3 state key and recorded in state.json for
|
|
7742
|
+
* cross-region destroy.
|
|
7743
|
+
*/
|
|
7482
7744
|
stackRegion;
|
|
7483
7745
|
/**
|
|
7484
7746
|
* Deploy a CloudFormation template
|
|
@@ -7487,7 +7749,7 @@ var DeployEngine = class {
|
|
|
7487
7749
|
const startTime = Date.now();
|
|
7488
7750
|
this.logger.debug(`Starting deployment for stack: ${stackName}`);
|
|
7489
7751
|
setCurrentStackName(stackName);
|
|
7490
|
-
await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
|
|
7752
|
+
await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
|
|
7491
7753
|
const renderer = getLiveRenderer();
|
|
7492
7754
|
renderer.start();
|
|
7493
7755
|
this.interrupted = false;
|
|
@@ -7501,16 +7763,17 @@ var DeployEngine = class {
|
|
|
7501
7763
|
};
|
|
7502
7764
|
process.on("SIGINT", sigintHandler);
|
|
7503
7765
|
try {
|
|
7504
|
-
const currentStateData = await this.stateBackend.getState(stackName);
|
|
7766
|
+
const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
7505
7767
|
const currentState = currentStateData?.state ?? {
|
|
7506
|
-
version:
|
|
7507
|
-
|
|
7768
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
7769
|
+
region: this.stackRegion,
|
|
7508
7770
|
stackName,
|
|
7509
7771
|
resources: {},
|
|
7510
7772
|
outputs: {},
|
|
7511
7773
|
lastModified: Date.now()
|
|
7512
7774
|
};
|
|
7513
7775
|
const currentEtag = currentStateData?.etag;
|
|
7776
|
+
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
7514
7777
|
this.logger.debug(
|
|
7515
7778
|
`Loaded current state: ${Object.keys(currentState.resources).length} resources`
|
|
7516
7779
|
);
|
|
@@ -7596,9 +7859,10 @@ var DeployEngine = class {
|
|
|
7596
7859
|
parameterValues,
|
|
7597
7860
|
conditions,
|
|
7598
7861
|
currentEtag,
|
|
7599
|
-
progress
|
|
7862
|
+
progress,
|
|
7863
|
+
migrationPending
|
|
7600
7864
|
);
|
|
7601
|
-
const newEtag = await this.stateBackend.saveState(stackName, newState);
|
|
7865
|
+
const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
|
|
7602
7866
|
this.logger.debug(`State saved (ETag: ${newEtag})`);
|
|
7603
7867
|
const durationMs = Date.now() - startTime;
|
|
7604
7868
|
const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
|
|
@@ -7614,7 +7878,7 @@ var DeployEngine = class {
|
|
|
7614
7878
|
renderer.stop();
|
|
7615
7879
|
process.removeListener("SIGINT", sigintHandler);
|
|
7616
7880
|
try {
|
|
7617
|
-
await this.lockManager.releaseLock(stackName);
|
|
7881
|
+
await this.lockManager.releaseLock(stackName, this.stackRegion);
|
|
7618
7882
|
this.logger.debug("Lock released");
|
|
7619
7883
|
} catch (lockError) {
|
|
7620
7884
|
this.logger.warn(
|
|
@@ -7632,11 +7896,12 @@ var DeployEngine = class {
|
|
|
7632
7896
|
* - DELETE follows reverse dependency order (a node starts as soon as all
|
|
7633
7897
|
* resources that depend ON it have finished deleting)
|
|
7634
7898
|
*/
|
|
7635
|
-
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
|
|
7899
|
+
async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
|
|
7636
7900
|
const concurrency = this.options.concurrency;
|
|
7637
7901
|
const newResources = { ...currentState.resources };
|
|
7638
7902
|
const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
|
|
7639
7903
|
const completedOperations = [];
|
|
7904
|
+
let pendingMigration = migrationPending;
|
|
7640
7905
|
let saveChain = Promise.resolve();
|
|
7641
7906
|
const saveStateAfterResource = (logicalId) => {
|
|
7642
7907
|
if (currentEtag === void 0)
|
|
@@ -7644,14 +7909,23 @@ var DeployEngine = class {
|
|
|
7644
7909
|
saveChain = saveChain.then(async () => {
|
|
7645
7910
|
try {
|
|
7646
7911
|
const partialState = {
|
|
7647
|
-
version:
|
|
7648
|
-
|
|
7912
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
7913
|
+
region: this.stackRegion,
|
|
7649
7914
|
stackName: currentState.stackName,
|
|
7650
7915
|
resources: newResources,
|
|
7651
7916
|
outputs: currentState.outputs,
|
|
7652
7917
|
lastModified: Date.now()
|
|
7653
7918
|
};
|
|
7654
|
-
|
|
7919
|
+
const migrate = pendingMigration;
|
|
7920
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
7921
|
+
currentEtag = await this.stateBackend.saveState(
|
|
7922
|
+
stackName,
|
|
7923
|
+
this.stackRegion,
|
|
7924
|
+
partialState,
|
|
7925
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
7926
|
+
);
|
|
7927
|
+
if (migrate)
|
|
7928
|
+
pendingMigration = false;
|
|
7655
7929
|
this.logger.debug(`State saved after ${logicalId}`);
|
|
7656
7930
|
} catch (error) {
|
|
7657
7931
|
this.logger.warn(
|
|
@@ -7785,14 +8059,23 @@ var DeployEngine = class {
|
|
|
7785
8059
|
} catch (error) {
|
|
7786
8060
|
try {
|
|
7787
8061
|
const preRollbackState = {
|
|
7788
|
-
version:
|
|
7789
|
-
|
|
8062
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8063
|
+
region: this.stackRegion,
|
|
7790
8064
|
stackName: currentState.stackName,
|
|
7791
8065
|
resources: newResources,
|
|
7792
8066
|
outputs: currentState.outputs,
|
|
7793
8067
|
lastModified: Date.now()
|
|
7794
8068
|
};
|
|
7795
|
-
|
|
8069
|
+
const migrate = pendingMigration;
|
|
8070
|
+
const expectedEtag = migrate ? void 0 : currentEtag;
|
|
8071
|
+
currentEtag = await this.stateBackend.saveState(
|
|
8072
|
+
stackName,
|
|
8073
|
+
this.stackRegion,
|
|
8074
|
+
preRollbackState,
|
|
8075
|
+
{ ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
|
|
8076
|
+
);
|
|
8077
|
+
if (migrate)
|
|
8078
|
+
pendingMigration = false;
|
|
7796
8079
|
this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
|
|
7797
8080
|
} catch (saveError) {
|
|
7798
8081
|
this.logger.warn(
|
|
@@ -7813,31 +8096,35 @@ var DeployEngine = class {
|
|
|
7813
8096
|
}
|
|
7814
8097
|
try {
|
|
7815
8098
|
const postRollbackState = {
|
|
7816
|
-
version:
|
|
7817
|
-
|
|
8099
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8100
|
+
region: this.stackRegion,
|
|
7818
8101
|
stackName: currentState.stackName,
|
|
7819
8102
|
resources: newResources,
|
|
7820
8103
|
outputs: currentState.outputs,
|
|
7821
8104
|
lastModified: Date.now()
|
|
7822
8105
|
};
|
|
7823
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
8106
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
8107
|
+
...currentEtag !== void 0 && { expectedEtag: currentEtag }
|
|
8108
|
+
});
|
|
7824
8109
|
this.logger.debug("State saved after deployment failure");
|
|
7825
8110
|
} catch (saveError) {
|
|
7826
8111
|
this.logger.debug(
|
|
7827
8112
|
`Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
|
|
7828
8113
|
);
|
|
7829
8114
|
try {
|
|
7830
|
-
const freshState = await this.stateBackend.getState(stackName);
|
|
8115
|
+
const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
|
|
7831
8116
|
const freshEtag = freshState?.etag;
|
|
7832
8117
|
const postRollbackState = {
|
|
7833
|
-
version:
|
|
7834
|
-
|
|
8118
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8119
|
+
region: this.stackRegion,
|
|
7835
8120
|
stackName: currentState.stackName,
|
|
7836
8121
|
resources: newResources,
|
|
7837
8122
|
outputs: currentState.outputs,
|
|
7838
8123
|
lastModified: Date.now()
|
|
7839
8124
|
};
|
|
7840
|
-
await this.stateBackend.saveState(stackName, postRollbackState,
|
|
8125
|
+
await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
|
|
8126
|
+
...freshEtag !== void 0 && { expectedEtag: freshEtag }
|
|
8127
|
+
});
|
|
7841
8128
|
this.logger.debug("State saved after deployment failure (retry succeeded)");
|
|
7842
8129
|
} catch (retryError) {
|
|
7843
8130
|
this.logger.warn(
|
|
@@ -7856,8 +8143,8 @@ var DeployEngine = class {
|
|
|
7856
8143
|
);
|
|
7857
8144
|
return {
|
|
7858
8145
|
state: {
|
|
7859
|
-
version:
|
|
7860
|
-
|
|
8146
|
+
version: STATE_SCHEMA_VERSION_CURRENT,
|
|
8147
|
+
region: this.stackRegion,
|
|
7861
8148
|
stackName: currentState.stackName,
|
|
7862
8149
|
resources: newResources,
|
|
7863
8150
|
outputs,
|