@go-to-k/cdkd 0.8.0 → 0.10.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.
Binary file
package/dist/index.js CHANGED
@@ -783,10 +783,73 @@ Caused by: ${error.cause.message}`;
783
783
  }
784
784
  return String(error);
785
785
  }
786
+ function normalizeAwsError(err, context = {}) {
787
+ if (!(err instanceof Error)) {
788
+ return new Error(String(err));
789
+ }
790
+ const isUnknown = err.name === "Unknown" || err.message === "UnknownError";
791
+ if (!isUnknown)
792
+ return err;
793
+ const meta = err.$metadata;
794
+ const status = meta?.httpStatusCode;
795
+ const bucket = context.bucket ?? "<unknown bucket>";
796
+ const operation = context.operation ?? "operation";
797
+ switch (status) {
798
+ case 301: {
799
+ const responseHeaders = err.$response?.headers;
800
+ const region = responseHeaders?.["x-amz-bucket-region"] ?? responseHeaders?.["X-Amz-Bucket-Region"];
801
+ const where = region ? ` (in ${region})` : "";
802
+ return new Error(
803
+ `Bucket '${bucket}'${where} is in a different region than the client. cdkd resolves this automatically; if you see this message, please report it.`
804
+ );
805
+ }
806
+ case 403:
807
+ return new Error(
808
+ `Access denied to bucket '${bucket}'. Verify credentials and bucket policy.`
809
+ );
810
+ case 404:
811
+ return new Error(`Bucket '${bucket}' does not exist.`);
812
+ default: {
813
+ const statusStr = status !== void 0 ? `HTTP ${status}` : "unknown HTTP status";
814
+ return new Error(
815
+ `S3 error during ${operation} on '${bucket}' (${statusStr}). See CloudTrail for details.`
816
+ );
817
+ }
818
+ }
819
+ }
786
820
 
787
821
  // src/index.ts
788
822
  init_aws_clients();
789
823
 
824
+ // src/utils/aws-region-resolver.ts
825
+ import { GetBucketLocationCommand, S3Client as S3Client2 } from "@aws-sdk/client-s3";
826
+ var cache = /* @__PURE__ */ new Map();
827
+ async function resolveBucketRegion(bucketName, opts = {}) {
828
+ const cached = cache.get(bucketName);
829
+ if (cached)
830
+ return cached;
831
+ const promise = (async () => {
832
+ const client = new S3Client2({
833
+ region: "us-east-1",
834
+ ...opts.profile && { profile: opts.profile },
835
+ ...opts.credentials && { credentials: opts.credentials }
836
+ });
837
+ try {
838
+ const response = await client.send(new GetBucketLocationCommand({ Bucket: bucketName }));
839
+ return response.LocationConstraint || "us-east-1";
840
+ } catch {
841
+ return opts.fallbackRegion ?? "us-east-1";
842
+ } finally {
843
+ client.destroy();
844
+ }
845
+ })();
846
+ cache.set(bucketName, promise);
847
+ return promise;
848
+ }
849
+ function clearBucketRegionCache() {
850
+ cache.clear();
851
+ }
852
+
790
853
  // src/synthesis/synthesizer.ts
791
854
  import { existsSync as existsSync3, mkdirSync, statSync } from "node:fs";
792
855
  import { resolve as resolve3 } from "node:path";
@@ -2083,7 +2146,7 @@ import { readFileSync as readFileSync4 } from "node:fs";
2083
2146
  // src/assets/file-asset-publisher.ts
2084
2147
  import { createReadStream, statSync as statSync2 } from "node:fs";
2085
2148
  import { join as join4, basename } from "node:path";
2086
- import { S3Client as S3Client2, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
2149
+ import { S3Client as S3Client3, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
2087
2150
  var FileAssetPublisher = class {
2088
2151
  logger = getLogger().child("FileAssetPublisher");
2089
2152
  /**
@@ -2104,7 +2167,7 @@ var FileAssetPublisher = class {
2104
2167
  this.logger.debug(
2105
2168
  `Publishing file asset ${asset.displayName || assetHash} \u2192 s3://${bucketName}/${objectKey}`
2106
2169
  );
2107
- const client = new S3Client2({
2170
+ const client = new S3Client3({
2108
2171
  region: destRegion
2109
2172
  });
2110
2173
  try {
@@ -2653,6 +2716,7 @@ var AssetPublisher = class {
2653
2716
 
2654
2717
  // src/state/s3-state-backend.ts
2655
2718
  import {
2719
+ S3Client as S3Client4,
2656
2720
  GetObjectCommand,
2657
2721
  PutObjectCommand as PutObjectCommand2,
2658
2722
  DeleteObjectCommand,
@@ -2670,11 +2734,14 @@ var STATE_SCHEMA_VERSION_CURRENT = 2;
2670
2734
  var LEGACY_KEY_DEPTH = 2;
2671
2735
  var NEW_KEY_DEPTH = 3;
2672
2736
  var S3StateBackend = class {
2673
- constructor(s3Client, config) {
2737
+ constructor(s3Client, config, clientOpts = {}) {
2674
2738
  this.s3Client = s3Client;
2675
2739
  this.config = config;
2740
+ this.clientOpts = clientOpts;
2676
2741
  }
2677
2742
  logger = getLogger().child("S3StateBackend");
2743
+ clientResolved = false;
2744
+ resolveInFlight = null;
2678
2745
  /**
2679
2746
  * Get the new (region-scoped) S3 key for a stack's state file.
2680
2747
  */
@@ -2688,13 +2755,73 @@ var S3StateBackend = class {
2688
2755
  getLegacyStateKey(stackName) {
2689
2756
  return `${this.config.prefix}/${stackName}/state.json`;
2690
2757
  }
2758
+ /**
2759
+ * Resolve the state bucket's actual region and, if it differs from the
2760
+ * client's currently-configured region, replace the S3Client with one
2761
+ * pointed at the bucket's region.
2762
+ *
2763
+ * This is idempotent: subsequent calls return immediately. Concurrent
2764
+ * callers (e.g. when several public methods race during a parallel deploy)
2765
+ * share a single in-flight resolution promise so we never issue more than
2766
+ * one `GetBucketLocation` per backend.
2767
+ *
2768
+ * Errors from `GetBucketLocation` are deliberately swallowed by
2769
+ * `resolveBucketRegion` — the resolver returns `fallbackRegion` so the
2770
+ * caller can surface the more actionable downstream error (e.g. the
2771
+ * `HeadBucket` 404 routed via `normalizeAwsError`).
2772
+ */
2773
+ async ensureClientForBucket() {
2774
+ if (this.clientResolved)
2775
+ return;
2776
+ if (this.resolveInFlight)
2777
+ return this.resolveInFlight;
2778
+ this.resolveInFlight = (async () => {
2779
+ try {
2780
+ const currentRegion = await this.s3Client.config.region();
2781
+ const fallbackRegion = typeof currentRegion === "string" ? currentRegion : void 0;
2782
+ const bucketRegion = await resolveBucketRegion(this.config.bucket, {
2783
+ ...this.clientOpts.profile && { profile: this.clientOpts.profile },
2784
+ ...this.clientOpts.credentials && { credentials: this.clientOpts.credentials },
2785
+ ...fallbackRegion && { fallbackRegion }
2786
+ });
2787
+ if (bucketRegion !== currentRegion) {
2788
+ this.logger.debug(
2789
+ `State bucket '${this.config.bucket}' is in '${bucketRegion}' (client was '${currentRegion}'); rebuilding S3 client.`
2790
+ );
2791
+ const oldClient = this.s3Client;
2792
+ this.s3Client = new S3Client4({
2793
+ region: bucketRegion,
2794
+ ...this.clientOpts.profile && { profile: this.clientOpts.profile },
2795
+ ...this.clientOpts.credentials && { credentials: this.clientOpts.credentials },
2796
+ // Suppress "Are you using a Stream of unknown length" warning,
2797
+ // matching the suppression in AwsClients.
2798
+ logger: { debug: () => {
2799
+ }, info: () => {
2800
+ }, warn: () => {
2801
+ }, error: () => {
2802
+ } }
2803
+ });
2804
+ oldClient.destroy();
2805
+ }
2806
+ this.clientResolved = true;
2807
+ } finally {
2808
+ this.resolveInFlight = null;
2809
+ }
2810
+ })();
2811
+ return this.resolveInFlight;
2812
+ }
2691
2813
  /**
2692
2814
  * Verify that the configured state bucket exists.
2693
2815
  *
2694
2816
  * Called early in deploy/destroy to fail fast before expensive work
2695
2817
  * (asset publishing, Docker builds) runs against a missing bucket.
2818
+ *
2819
+ * Errors are routed through {@link normalizeAwsError} so the AWS SDK v3
2820
+ * synthetic `UnknownError` (e.g. cross-region HEAD) becomes a concrete
2821
+ * "Bucket does not exist" / "Access denied" / "different region" message.
2696
2822
  */
2697
2823
  async verifyBucketExists() {
2824
+ await this.ensureClientForBucket();
2698
2825
  try {
2699
2826
  await this.s3Client.send(new HeadBucketCommand({ Bucket: this.config.bucket }));
2700
2827
  } catch (error) {
@@ -2704,9 +2831,13 @@ var S3StateBackend = class {
2704
2831
  `State bucket '${this.config.bucket}' does not exist. Run 'cdkd bootstrap' to create it, or specify an existing bucket via --state-bucket, CDKD_STATE_BUCKET, or cdk.json context.cdkd.stateBucket.`
2705
2832
  );
2706
2833
  }
2834
+ const normalized = normalizeAwsError(error, {
2835
+ bucket: this.config.bucket,
2836
+ operation: "HeadBucket"
2837
+ });
2707
2838
  throw new StateError(
2708
- `Failed to verify state bucket '${this.config.bucket}': ${error instanceof Error ? error.message : String(error)}`,
2709
- error instanceof Error ? error : void 0
2839
+ `Failed to verify state bucket '${this.config.bucket}': ${normalized.message}`,
2840
+ normalized
2710
2841
  );
2711
2842
  }
2712
2843
  }
@@ -2719,6 +2850,7 @@ var S3StateBackend = class {
2719
2850
  * state without forcing a write-through migration first.
2720
2851
  */
2721
2852
  async stateExists(stackName, region) {
2853
+ await this.ensureClientForBucket();
2722
2854
  const newKey = this.getStateKey(stackName, region);
2723
2855
  if (await this.headObject(newKey)) {
2724
2856
  return true;
@@ -2741,6 +2873,7 @@ var S3StateBackend = class {
2741
2873
  * preserve the quotes — they are required for `IfMatch` conditions.
2742
2874
  */
2743
2875
  async getState(stackName, region) {
2876
+ await this.ensureClientForBucket();
2744
2877
  const newKey = this.getStateKey(stackName, region);
2745
2878
  try {
2746
2879
  this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
@@ -2800,6 +2933,7 @@ var S3StateBackend = class {
2800
2933
  * @returns New ETag (with quotes, e.g., `"abc123"`)
2801
2934
  */
2802
2935
  async saveState(stackName, region, state, options = {}) {
2936
+ await this.ensureClientForBucket();
2803
2937
  const newKey = this.getStateKey(stackName, region);
2804
2938
  const { expectedEtag, migrateLegacy } = options;
2805
2939
  const body = {
@@ -2855,9 +2989,13 @@ var S3StateBackend = class {
2855
2989
  `State has been modified by another process. Expected ETag: ${expectedEtag}, but state has changed.`
2856
2990
  );
2857
2991
  }
2992
+ const normalized = normalizeAwsError(error, {
2993
+ bucket: this.config.bucket,
2994
+ operation: "PutObject"
2995
+ });
2858
2996
  throw new StateError(
2859
- `Failed to save state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2860
- error instanceof Error ? error : void 0
2997
+ `Failed to save state for stack '${stackName}' (${region}): ${normalized.message}`,
2998
+ normalized
2861
2999
  );
2862
3000
  }
2863
3001
  }
@@ -2869,6 +3007,7 @@ var S3StateBackend = class {
2869
3007
  * field is left alone.
2870
3008
  */
2871
3009
  async deleteState(stackName, region) {
3010
+ await this.ensureClientForBucket();
2872
3011
  try {
2873
3012
  this.logger.debug(`Deleting state: ${stackName} (${region})`);
2874
3013
  await this.s3Client.send(
@@ -2888,9 +3027,13 @@ var S3StateBackend = class {
2888
3027
  }
2889
3028
  this.logger.debug(`State deleted: ${stackName} (${region})`);
2890
3029
  } catch (error) {
3030
+ const normalized = normalizeAwsError(error, {
3031
+ bucket: this.config.bucket,
3032
+ operation: "DeleteObject"
3033
+ });
2891
3034
  throw new StateError(
2892
- `Failed to delete state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
2893
- error instanceof Error ? error : void 0
3035
+ `Failed to delete state for stack '${stackName}' (${region}): ${normalized.message}`,
3036
+ normalized
2894
3037
  );
2895
3038
  }
2896
3039
  }
@@ -2909,6 +3052,7 @@ var S3StateBackend = class {
2909
3052
  * shows up exactly once.
2910
3053
  */
2911
3054
  async listStacks() {
3055
+ await this.ensureClientForBucket();
2912
3056
  try {
2913
3057
  this.logger.debug("Listing all stacks");
2914
3058
  const prefix = `${this.config.prefix}/`;
@@ -2959,10 +3103,11 @@ var S3StateBackend = class {
2959
3103
  this.logger.debug(`Found ${refs.length} stack(s) across regions`);
2960
3104
  return refs;
2961
3105
  } 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
- );
3106
+ const normalized = normalizeAwsError(error, {
3107
+ bucket: this.config.bucket,
3108
+ operation: "ListObjectsV2"
3109
+ });
3110
+ throw new StateError(`Failed to list stacks: ${normalized.message}`, normalized);
2966
3111
  }
2967
3112
  }
2968
3113
  /**
@@ -5652,6 +5797,29 @@ var JsonPatchGenerator = class {
5652
5797
  }
5653
5798
  };
5654
5799
 
5800
+ // src/provisioning/region-check.ts
5801
+ function assertRegionMatch(clientRegion, expectedRegion, resourceType, logicalId, physicalId) {
5802
+ if (!expectedRegion) {
5803
+ return;
5804
+ }
5805
+ if (!clientRegion) {
5806
+ throw new ProvisioningError(
5807
+ `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.`,
5808
+ resourceType,
5809
+ logicalId,
5810
+ physicalId
5811
+ );
5812
+ }
5813
+ if (clientRegion !== expectedRegion) {
5814
+ throw new ProvisioningError(
5815
+ `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}).`,
5816
+ resourceType,
5817
+ logicalId,
5818
+ physicalId
5819
+ );
5820
+ }
5821
+ }
5822
+
5655
5823
  // src/provisioning/cloud-control-provider.ts
5656
5824
  var JSON_STRING_PROPERTIES = {
5657
5825
  "AWS::Events::Rule": /* @__PURE__ */ new Set(["EventPattern"])
@@ -5827,7 +5995,7 @@ var CloudControlProvider = class {
5827
5995
  /**
5828
5996
  * Delete a resource using Cloud Control API
5829
5997
  */
5830
- async delete(logicalId, physicalId, resourceType, _properties) {
5998
+ async delete(logicalId, physicalId, resourceType, _properties, context) {
5831
5999
  this.logger.debug(
5832
6000
  `Deleting resource ${logicalId} (${resourceType}), physical ID: ${physicalId}`
5833
6001
  );
@@ -5854,6 +6022,14 @@ var CloudControlProvider = class {
5854
6022
  } catch (error) {
5855
6023
  const err = error;
5856
6024
  if (err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound")) {
6025
+ const clientRegion = await this.cloudControlClient.config.region();
6026
+ assertRegionMatch(
6027
+ clientRegion,
6028
+ context?.expectedRegion,
6029
+ resourceType,
6030
+ logicalId,
6031
+ physicalId
6032
+ );
5857
6033
  this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
5858
6034
  return;
5859
6035
  }
@@ -6229,7 +6405,7 @@ Error: ${err.message || "Unknown error"}`,
6229
6405
  import { InvokeCommand } from "@aws-sdk/client-lambda";
6230
6406
  import { PublishCommand } from "@aws-sdk/client-sns";
6231
6407
  import {
6232
- S3Client as S3Client5,
6408
+ S3Client as S3Client6,
6233
6409
  PutObjectCommand as PutObjectCommand4,
6234
6410
  GetObjectCommand as GetObjectCommand3,
6235
6411
  DeleteObjectCommand as DeleteObjectCommand3
@@ -6298,7 +6474,7 @@ var CustomResourceProvider = class _CustomResourceProvider {
6298
6474
  setResponseBucket(bucket, bucketRegion) {
6299
6475
  this.responseBucket = bucket;
6300
6476
  if (bucketRegion) {
6301
- this.s3Client = new S3Client5(bucketRegion ? { region: bucketRegion } : {});
6477
+ this.s3Client = new S3Client6(bucketRegion ? { region: bucketRegion } : {});
6302
6478
  }
6303
6479
  }
6304
6480
  /**
@@ -6418,7 +6594,7 @@ var CustomResourceProvider = class _CustomResourceProvider {
6418
6594
  /**
6419
6595
  * Delete a custom resource by invoking its Lambda handler
6420
6596
  */
6421
- async delete(logicalId, physicalId, resourceType, properties) {
6597
+ async delete(logicalId, physicalId, resourceType, properties, _context) {
6422
6598
  this.logger.debug(`Deleting custom resource ${logicalId}: ${physicalId} (${resourceType})`);
6423
6599
  if (!properties) {
6424
6600
  this.logger.warn(
@@ -7240,13 +7416,21 @@ var IAMRoleProvider = class {
7240
7416
  * 3. Remove role from all instance profiles
7241
7417
  * 4. Delete the role itself
7242
7418
  */
7243
- async delete(logicalId, physicalId, resourceType, _properties) {
7419
+ async delete(logicalId, physicalId, resourceType, _properties, context) {
7244
7420
  this.logger.debug(`Deleting IAM role ${logicalId}: ${physicalId}`);
7245
7421
  try {
7246
7422
  try {
7247
7423
  await this.iamClient.send(new GetRoleCommand({ RoleName: physicalId }));
7248
7424
  } catch (error) {
7249
7425
  if (error instanceof NoSuchEntityException) {
7426
+ const clientRegion = await this.iamClient.config.region();
7427
+ assertRegionMatch(
7428
+ clientRegion,
7429
+ context?.expectedRegion,
7430
+ resourceType,
7431
+ logicalId,
7432
+ physicalId
7433
+ );
7250
7434
  this.logger.debug(`Role ${physicalId} does not exist, skipping deletion`);
7251
7435
  return;
7252
7436
  }
@@ -8273,7 +8457,9 @@ var DeployEngine = class {
8273
8457
  ` Rollback: Deleting created resource ${op.logicalId} (${op.resourceType})`
8274
8458
  );
8275
8459
  const provider = this.providerRegistry.getProvider(op.resourceType);
8276
- await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties);
8460
+ await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties, {
8461
+ expectedRegion: this.stackRegion
8462
+ });
8277
8463
  delete stateResources[op.logicalId];
8278
8464
  this.logger.info(` Rollback: ${op.logicalId} deleted successfully`);
8279
8465
  break;
@@ -8415,7 +8601,8 @@ var DeployEngine = class {
8415
8601
  logicalId,
8416
8602
  currentResource.physicalId,
8417
8603
  resourceType,
8418
- currentResource.properties
8604
+ currentResource.properties,
8605
+ { expectedRegion: this.stackRegion }
8419
8606
  );
8420
8607
  this.logger.info(` \u2713 Old resource deleted`);
8421
8608
  } catch (deleteError) {
@@ -8464,7 +8651,8 @@ var DeployEngine = class {
8464
8651
  logicalId,
8465
8652
  currentResource.physicalId,
8466
8653
  resourceType,
8467
- currentProps
8654
+ currentProps,
8655
+ { expectedRegion: this.stackRegion }
8468
8656
  );
8469
8657
  } catch (deleteError) {
8470
8658
  const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
@@ -8535,7 +8723,8 @@ var DeployEngine = class {
8535
8723
  logicalId,
8536
8724
  currentResource.physicalId,
8537
8725
  resourceType,
8538
- currentResource.properties
8726
+ currentResource.properties,
8727
+ { expectedRegion: this.stackRegion }
8539
8728
  ),
8540
8729
  logicalId,
8541
8730
  3,
@@ -8800,11 +8989,14 @@ export {
8800
8989
  SynthesisError,
8801
8990
  Synthesizer,
8802
8991
  TemplateParser,
8992
+ clearBucketRegionCache,
8803
8993
  formatError,
8804
8994
  getAwsClients,
8805
8995
  getLogger,
8806
8996
  isCdkdError,
8997
+ normalizeAwsError,
8807
8998
  resetAwsClients,
8999
+ resolveBucketRegion,
8808
9000
  setAwsClients,
8809
9001
  setLogger
8810
9002
  };