@go-to-k/cdkd 0.231.12 → 0.232.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 CHANGED
@@ -14,6 +14,8 @@ Drop-in CDK CLI for existing CDK apps — faster deploys via AWS SDK instead of
14
14
 
15
15
  **cdkd complements the AWS CDK CLI rather than replacing it.** Use cdkd in dev/test for rapid iteration and SAM-style local execution; use the AWS CDK CLI in production for full CloudFormation tooling. Install cdkd alongside an existing `cdk deploy` workflow — no migration needed, `cdkd local *` reads deployed state directly via `--from-cfn-stack`. Bidirectional migration is also supported — [import](#importing-existing-resources) into cdkd or [export](#exporting-a-stack-back-to-cloudformation) back to CloudFormation when ready.
16
16
 
17
+ **A natural fit for AI-driven development.** AI coding agents iterate in tight spin-up / tear-down loops — and cdkd keeps each turn short, with fast deploys and an equally fast `cdkd destroy` that deletes via direct SDK calls instead of polling a CloudFormation stack-delete.
18
+
17
19
  > [!IMPORTANT]
18
20
  > cdkd is for dev/test workflows only — early in development, not yet production-ready.
19
21
 
@@ -109,10 +111,16 @@ Reproduce the first two with `./tests/benchmark/run-benchmark.sh all`. See [test
109
111
 
110
112
 
111
113
  ┌─────────────────┐
114
+ │ Asset Build & │ S3 ZIP upload / ECR image build & push
115
+ │ Publish │
116
+ └────────┬────────┘
117
+
118
+
119
+ ┌─────────────────┐
112
120
  │ cdkd Engine │
113
121
  │ - DAG Analysis │ Dependency graph construction
114
122
  │ - Diff Calc │ Compare with existing resources
115
- │ - Parallel Exec │ Event-driven dispatch
123
+ │ - Parallel Exec │ Dispatch on deps complete (no level barrier)
116
124
  └────────┬────────┘
117
125
 
118
126
  ┌────┴────┐
@@ -131,7 +139,7 @@ parsing → synthesis → asset publishing → per-stack deploy), see
131
139
  ## Prerequisites
132
140
 
133
141
  - **Node.js** >= 20.0.0
134
- - **AWS CDK Bootstrap**: You must run `cdk bootstrap` before using cdkd. cdkd uses CDK's bootstrap bucket (`cdk-hnb659fds-assets-*`) for asset uploads (Lambda code, Docker images). Custom bootstrap qualifiers are supported — CDK embeds the correct bucket/repo names in the asset manifest during synthesis.
142
+ - **AWS CDK Bootstrap**: You must run `cdk bootstrap` before using cdkd. cdkd uses CDK's bootstrap bucket (`cdk-hnb659fds-assets-*`) for asset uploads (Lambda code, Docker images). Custom bootstrap qualifiers are supported — CDK embeds the correct bucket/repo names in the asset manifest during synthesis. Note: `cdk gc` cannot see cdkd-deployed stacks (no CloudFormation stack to scan) and may garbage-collect cdkd-published assets from the CDK bootstrap storage — `cdkd bootstrap` therefore also creates cdkd-owned asset storage that asset publishing is moving onto (issue [#1002](https://github.com/go-to-k/cdkd/issues/1002); design in [docs/design/1002-cdkd-asset-storage.md](docs/design/1002-cdkd-asset-storage.md); the publish redirection ships in a follow-up release).
135
143
  - **AWS credentials with admin-equivalent permissions** for the resources being deployed. cdkd does NOT route through CloudFormation, so CDK CLI's `cdk-hnb659fds-deploy-role-*` is NOT sufficient — see [`--role-arn`](docs/cli-reference.md).
136
144
 
137
145
  ## Installation
@@ -148,12 +156,16 @@ The installed binary is `cdkd`.
148
156
  > **First-time setup**: cdkd requires a one-time `cdkd bootstrap` per AWS
149
157
  > account before any other command will work — it creates the S3 state
150
158
  > bucket (`cdkd-state-{accountId}`) that cdkd uses to track deployed
151
- > resources. This is separate from `cdk bootstrap` (which sets up the
159
+ > resources, plus cdkd-owned asset storage for the region
160
+ > (`cdkd-assets-{accountId}-{region}` bucket +
161
+ > `cdkd-container-assets-{accountId}-{region}` ECR repo — skip with
162
+ > `--no-assets`; see [`cdkd bootstrap`](docs/cli-reference.md#cdkd-bootstrap)).
163
+ > This is separate from `cdk bootstrap` (which sets up the
152
164
  > CDK asset bucket / ECR repo and is also required — see
153
165
  > [Prerequisites](#prerequisites)).
154
166
 
155
167
  ```bash
156
- # Bootstrap (creates S3 state bucket — one-time setup, once per AWS account)
168
+ # Bootstrap (creates S3 state bucket + asset storage — one-time setup per AWS account)
157
169
  cdkd bootstrap
158
170
 
159
171
  # List stacks in the CDK app
package/dist/cli.js CHANGED
@@ -598,12 +598,320 @@ const destroyOptions = [
598
598
  allowUnsupportedTypesOption
599
599
  ];
600
600
 
601
+ //#endregion
602
+ //#region src/assets/asset-storage.ts
603
+ /**
604
+ * cdkd-owned asset storage — naming, bootstrap marker, and deploy-time
605
+ * asset-mode detection (issue #1002, design at
606
+ * docs/design/1002-cdkd-asset-storage.md).
607
+ *
608
+ * Why this exists: cdkd publishes assets to the CDK bootstrap bucket / ECR
609
+ * repo, but `cdk gc` decides "in use" by scanning CloudFormation stack
610
+ * templates — cdkd-deployed stacks have no CFn stack, so every cdkd-published
611
+ * asset looks isolated and gets deleted. The fix is cdkd-owned asset storage
612
+ * created by `cdkd bootstrap` (structurally out of `cdk gc`'s reach because
613
+ * gc only discovers storage from the CDK bootstrap stack's outputs).
614
+ *
615
+ * The upgrade is strictly transparent: deploys behave byte-identically to
616
+ * before until the user re-runs `cdkd bootstrap` for a region, which writes a
617
+ * per-region marker object into the state bucket. Deploy reads the marker to
618
+ * pick the mode; PR 2 of the phasing wires the actual publish redirection +
619
+ * template rewrite onto the `cdkd-assets` mode resolved here.
620
+ */
621
+ /** Marker schema version, bumped if the marker shape ever changes. */
622
+ const ASSET_SUPPORT_VERSION = 1;
623
+ /**
624
+ * S3 key prefix for bootstrap markers in the state bucket. Deliberately
625
+ * outside the `cdkd/` state prefix so `state list` key-listing never mistakes
626
+ * a marker for a stack, and concurrent bootstraps in two regions cannot race
627
+ * last-writer-wins on a shared object (design §12.1).
628
+ */
629
+ const BOOTSTRAP_MARKER_PREFIX = "cdkd-bootstrap/";
630
+ /**
631
+ * Name of the cdkd-owned asset bucket for an (account, region) pair.
632
+ * Per-region because Lambda requires the code bucket to be in the function's
633
+ * region — the same reason CDK's asset bucket is per-region (design §3).
634
+ */
635
+ function getCdkdAssetBucketName(accountId, region) {
636
+ return `cdkd-assets-${accountId}-${region}`;
637
+ }
638
+ /**
639
+ * Name of the cdkd-owned container-asset ECR repository for an
640
+ * (account, region) pair. ECR repos are account+region scoped by ARN, so the
641
+ * suffix is not strictly needed — the CDK-parallel shape keeps the future
642
+ * template rewrite uniform and the names self-describing (design §3).
643
+ */
644
+ function getCdkdContainerRepoName(accountId, region) {
645
+ return `cdkd-container-assets-${accountId}-${region}`;
646
+ }
647
+ /**
648
+ * State-bucket key of the bootstrap marker for a region.
649
+ */
650
+ function getBootstrapMarkerKey(region) {
651
+ return `${BOOTSTRAP_MARKER_PREFIX}${region}.json`;
652
+ }
653
+ /**
654
+ * Parse and validate a bootstrap marker body.
655
+ *
656
+ * Throws on malformed JSON or missing required fields — a corrupt marker is
657
+ * treated like the marker-present-but-resources-missing case (hard error,
658
+ * never a silent legacy fallback that would flip-flop stack properties
659
+ * between deploys — design §4.1).
660
+ */
661
+ function parseBootstrapMarker(body, markerKey) {
662
+ let parsed;
663
+ try {
664
+ parsed = JSON.parse(body);
665
+ } catch (error) {
666
+ throw new CdkdError(`Bootstrap marker '${markerKey}' in the state bucket is not valid JSON. Re-run 'cdkd bootstrap' for this region to rewrite it.`, "INVALID_BOOTSTRAP_MARKER", error);
667
+ }
668
+ const marker = parsed;
669
+ if (typeof marker.assetBucket !== "string" || marker.assetBucket.length === 0 || typeof marker.containerRepo !== "string" || marker.containerRepo.length === 0 || typeof marker.assetSupportVersion !== "number") throw new CdkdError(`Bootstrap marker '${markerKey}' in the state bucket is malformed (missing assetBucket / containerRepo / assetSupportVersion). Re-run 'cdkd bootstrap' for this region to rewrite it.`, "INVALID_BOOTSTRAP_MARKER");
670
+ if (marker.assetSupportVersion > 1) throw new CdkdError(`Bootstrap marker '${markerKey}' has assetSupportVersion ${marker.assetSupportVersion}, but this cdkd only understands up to ${1}. Upgrade cdkd to deploy in this region.`, "UNSUPPORTED_BOOTSTRAP_MARKER_VERSION");
671
+ return {
672
+ assetBucket: marker.assetBucket,
673
+ containerRepo: marker.containerRepo,
674
+ assetSupportVersion: marker.assetSupportVersion,
675
+ createdAt: typeof marker.createdAt === "string" ? marker.createdAt : ""
676
+ };
677
+ }
678
+ /**
679
+ * Verify that the asset bucket + ECR repo named by a marker actually exist.
680
+ *
681
+ * Called when a marker is present: the user opted the region in, so missing
682
+ * resources (user deleted them) are a hard error naming the missing resource
683
+ * and the `cdkd bootstrap` fix — never a silent fallback to CDK bootstrap
684
+ * storage (design §4.1).
685
+ *
686
+ * Every S3 call passes `ExpectedBucketOwner` so a marker pointing at a
687
+ * since-recreated foreign bucket can never leak assets cross-account
688
+ * (bucket-squatting defense, design §5).
689
+ */
690
+ async function verifyAssetStorageExists(marker, accountId, region, opts = {}) {
691
+ const rebootstrapHint = `Run 'cdkd bootstrap --region ${region}' to recreate it. cdkd never silently falls back to CDK bootstrap asset storage once a region is opted in.`;
692
+ const clientOpts = {
693
+ region,
694
+ ...opts.profile && { profile: opts.profile }
695
+ };
696
+ const s3Client = new S3Client(clientOpts);
697
+ const ecrClient = new ECRClient(clientOpts);
698
+ try {
699
+ try {
700
+ await s3Client.send(new HeadBucketCommand({
701
+ Bucket: marker.assetBucket,
702
+ ExpectedBucketOwner: accountId
703
+ }));
704
+ } catch (error) {
705
+ const err = error;
706
+ if (err.name === "NotFound" || err.name === "NoSuchBucket") throw new CdkdError(`cdkd asset storage is bootstrapped for region '${region}' but the asset bucket '${marker.assetBucket}' is missing. ${rebootstrapHint}`, "ASSET_STORAGE_MISSING");
707
+ if (err.$metadata?.httpStatusCode === 403) throw new CdkdError(`Asset bucket '${marker.assetBucket}' exists but is not owned by account ${accountId} (or access is denied). Refusing to use it. ${rebootstrapHint}`, "ASSET_STORAGE_FOREIGN_BUCKET", error);
708
+ throw error;
709
+ }
710
+ try {
711
+ await ecrClient.send(new DescribeRepositoriesCommand({ repositoryNames: [marker.containerRepo] }));
712
+ } catch (error) {
713
+ if (error.name === "RepositoryNotFoundException") throw new CdkdError(`cdkd asset storage is bootstrapped for region '${region}' but the container-asset ECR repository '${marker.containerRepo}' is missing. ${rebootstrapHint}`, "ASSET_STORAGE_MISSING");
714
+ throw error;
715
+ }
716
+ } finally {
717
+ s3Client.destroy();
718
+ ecrClient.destroy();
719
+ }
720
+ }
721
+ /**
722
+ * Create (or adopt, when already owned by this account) the cdkd asset
723
+ * bucket + container-asset ECR repo for a region, then write the bootstrap
724
+ * marker. Called by `cdkd bootstrap` unless `--no-assets` is passed.
725
+ *
726
+ * Idempotent like the state-bucket path: existing resources are left as-is
727
+ * unless `force` re-applies their configuration. The marker is written LAST,
728
+ * only after both resources exist (design §5) — a crash mid-bootstrap leaves
729
+ * no marker, so deploys stay in legacy mode.
730
+ *
731
+ * Bucket-squatting defense: the bucket name is predictable (same weakness as
732
+ * CDK's bootstrap bucket), so this refuses to adopt a bucket this account
733
+ * does not own, and the `HeadBucket` probe passes `ExpectedBucketOwner`.
734
+ */
735
+ async function ensureAssetStorage(options) {
736
+ const logger = getLogger();
737
+ const { s3Client, ecrClient, stateBackend, accountId, region, force } = options;
738
+ const assetBucket = getCdkdAssetBucketName(accountId, region);
739
+ const containerRepo = getCdkdContainerRepoName(accountId, region);
740
+ let bucketExists = false;
741
+ try {
742
+ await s3Client.send(new HeadBucketCommand({
743
+ Bucket: assetBucket,
744
+ ExpectedBucketOwner: accountId
745
+ }));
746
+ bucketExists = true;
747
+ logger.info(`Asset bucket ${assetBucket} already exists`);
748
+ } catch (error) {
749
+ const err = error;
750
+ if (err.name === "NotFound" || err.name === "NoSuchBucket") {} else if (err.$metadata?.httpStatusCode === 403) throw new CdkdError(`Asset bucket name '${assetBucket}' is already taken by a bucket this account does not own (or access is denied). Refusing to adopt it — resolve the naming conflict before re-running 'cdkd bootstrap'.`, "ASSET_STORAGE_FOREIGN_BUCKET", error);
751
+ else throw normalizeAwsError(error, {
752
+ bucket: assetBucket,
753
+ operation: "HeadBucket"
754
+ });
755
+ }
756
+ if (!bucketExists) {
757
+ logger.info(`Creating asset bucket: ${assetBucket} in region ${region}`);
758
+ try {
759
+ await s3Client.send(new CreateBucketCommand({
760
+ Bucket: assetBucket,
761
+ ...region !== "us-east-1" && { CreateBucketConfiguration: { LocationConstraint: region } }
762
+ }));
763
+ logger.info(`✓ Created asset bucket: ${assetBucket}`);
764
+ } catch (error) {
765
+ const err = error;
766
+ if (err.name === "BucketAlreadyOwnedByYou") logger.info(`Asset bucket ${assetBucket} already exists`);
767
+ else if (err.name === "BucketAlreadyExists") throw new CdkdError(`Asset bucket name '${assetBucket}' is already taken by another AWS account. Refusing to adopt it — resolve the naming conflict before re-running 'cdkd bootstrap'.`, "ASSET_STORAGE_FOREIGN_BUCKET", error);
768
+ else throw normalizeAwsError(error, {
769
+ bucket: assetBucket,
770
+ operation: "CreateBucket"
771
+ });
772
+ }
773
+ }
774
+ if (!bucketExists || force) {
775
+ await s3Client.send(new PutBucketEncryptionCommand({
776
+ Bucket: assetBucket,
777
+ ExpectedBucketOwner: accountId,
778
+ ServerSideEncryptionConfiguration: { Rules: [{
779
+ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
780
+ BucketKeyEnabled: true
781
+ }] }
782
+ }));
783
+ await s3Client.send(new PutPublicAccessBlockCommand({
784
+ Bucket: assetBucket,
785
+ ExpectedBucketOwner: accountId,
786
+ PublicAccessBlockConfiguration: {
787
+ BlockPublicAcls: true,
788
+ BlockPublicPolicy: true,
789
+ IgnorePublicAcls: true,
790
+ RestrictPublicBuckets: true
791
+ }
792
+ }));
793
+ await s3Client.send(new PutBucketPolicyCommand({
794
+ Bucket: assetBucket,
795
+ ExpectedBucketOwner: accountId,
796
+ Policy: JSON.stringify({
797
+ Version: "2012-10-17",
798
+ Statement: [{
799
+ Sid: "DenyExternalAccess",
800
+ Effect: "Deny",
801
+ Principal: "*",
802
+ Action: "s3:*",
803
+ Resource: [`arn:aws:s3:::${assetBucket}`, `arn:aws:s3:::${assetBucket}/*`],
804
+ Condition: { StringNotEquals: { "aws:PrincipalAccount": accountId } }
805
+ }]
806
+ })
807
+ }));
808
+ logger.info("✓ Configured asset bucket (AES-256 encryption, public access block, deny external access)");
809
+ }
810
+ let repoExists = false;
811
+ try {
812
+ await ecrClient.send(new DescribeRepositoriesCommand({ repositoryNames: [containerRepo] }));
813
+ repoExists = true;
814
+ logger.info(`Container-asset repository ${containerRepo} already exists`);
815
+ } catch (error) {
816
+ if (error.name !== "RepositoryNotFoundException") throw error;
817
+ }
818
+ if (!repoExists) {
819
+ logger.info(`Creating container-asset ECR repository: ${containerRepo}`);
820
+ try {
821
+ await ecrClient.send(new CreateRepositoryCommand({
822
+ repositoryName: containerRepo,
823
+ imageTagMutability: "IMMUTABLE"
824
+ }));
825
+ logger.info(`✓ Created container-asset ECR repository: ${containerRepo}`);
826
+ } catch (error) {
827
+ if (error.name === "RepositoryAlreadyExistsException") logger.info(`Container-asset repository ${containerRepo} already exists`);
828
+ else throw error;
829
+ }
830
+ } else if (force) {
831
+ await ecrClient.send(new PutImageTagMutabilityCommand({
832
+ repositoryName: containerRepo,
833
+ imageTagMutability: "IMMUTABLE"
834
+ }));
835
+ logger.info("✓ Configured container-asset repository (immutable tags)");
836
+ }
837
+ const marker = {
838
+ assetBucket,
839
+ containerRepo,
840
+ assetSupportVersion: 1,
841
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
842
+ };
843
+ await stateBackend.putRawObject(getBootstrapMarkerKey(region), JSON.stringify(marker, null, 2));
844
+ logger.info(`✓ Wrote bootstrap marker (${getBootstrapMarkerKey(region)})`);
845
+ return {
846
+ assetBucket,
847
+ containerRepo
848
+ };
849
+ }
850
+ /**
851
+ * Deploy-time asset-mode resolver. One instance per CLI invocation; results
852
+ * are cached per region for the process lifetime (the marker read is one
853
+ * GetObject against a bucket every deploy already reads — design §4.1).
854
+ *
855
+ * In legacy mode, one `logger.info` line per invocation mentions the
856
+ * `cdk gc` hazard and the `cdkd bootstrap` opt-in (info, not warn — existing
857
+ * users are not doing anything wrong; design §12.2).
858
+ */
859
+ var AssetModeResolver = class {
860
+ logger = getLogger().child("AssetMode");
861
+ cache = /* @__PURE__ */ new Map();
862
+ legacyNoticeShown = false;
863
+ stateBackend;
864
+ accountId;
865
+ profile;
866
+ constructor(stateBackend, accountId, opts = {}) {
867
+ this.stateBackend = stateBackend;
868
+ this.accountId = accountId;
869
+ this.profile = opts.profile;
870
+ }
871
+ /**
872
+ * Resolve the asset mode for a deploy region. Concurrent callers for the
873
+ * same region share one in-flight resolution.
874
+ */
875
+ resolve(region) {
876
+ const cached = this.cache.get(region);
877
+ if (cached) return cached;
878
+ const inFlight = this.doResolve(region).catch((error) => {
879
+ this.cache.delete(region);
880
+ throw error;
881
+ });
882
+ this.cache.set(region, inFlight);
883
+ return inFlight;
884
+ }
885
+ async doResolve(region) {
886
+ const markerKey = getBootstrapMarkerKey(region);
887
+ const body = await this.stateBackend.getRawObject(markerKey);
888
+ if (body === null) {
889
+ if (!this.legacyNoticeShown) {
890
+ this.legacyNoticeShown = true;
891
+ this.logger.info("Assets are published to the CDK bootstrap bucket/repo, which 'cdk gc' may garbage-collect (cdkd-deployed stacks have no CloudFormation stack for gc to scan). Run 'cdkd bootstrap' to create cdkd-owned asset storage that 'cdk gc' never touches.");
892
+ }
893
+ return { mode: "legacy" };
894
+ }
895
+ const marker = parseBootstrapMarker(body, markerKey);
896
+ await verifyAssetStorageExists(marker, this.accountId, region, { ...this.profile && { profile: this.profile } });
897
+ this.logger.debug(`cdkd asset storage active for region '${region}': ${marker.assetBucket} / ${marker.containerRepo}`);
898
+ return {
899
+ mode: "cdkd-assets",
900
+ marker
901
+ };
902
+ }
903
+ };
904
+
601
905
  //#endregion
602
906
  //#region src/cli/commands/bootstrap.ts
603
907
  /**
604
908
  * Bootstrap command implementation
605
909
  *
606
- * Creates S3 bucket for state management
910
+ * Creates the S3 bucket for state management and (unless --no-assets) the
911
+ * cdkd-owned asset storage for the region: asset bucket + container-asset
912
+ * ECR repo + the per-region bootstrap marker that flips deploys in this
913
+ * region from legacy (CDK bootstrap destinations) to cdkd-assets mode
914
+ * (issue #1002, design at docs/design/1002-cdkd-asset-storage.md).
607
915
  */
608
916
  async function bootstrapCommand(options) {
609
917
  const logger = getLogger();
@@ -646,54 +954,96 @@ async function bootstrapCommand(options) {
646
954
  operation: "HeadBucket"
647
955
  });
648
956
  }
649
- if (bucketExists) {
650
- if (!options.force) {
957
+ let skipStateBucketConfig = false;
958
+ if (bucketExists) if (!options.force) {
959
+ if (!options.assets) {
651
960
  logger.warn(`Bucket ${bucketName} already exists. Use --force to reconfigure (this will not delete existing state)`);
652
961
  return;
653
962
  }
654
- logger.info("--force specified, continuing with existing bucket");
655
- } else {
963
+ logger.info(`State bucket ${bucketName} already exists — skipping reconfiguration (use --force to reconfigure)`);
964
+ skipStateBucketConfig = true;
965
+ } else logger.info("--force specified, continuing with existing bucket");
966
+ else {
656
967
  logger.info(`Creating S3 bucket: ${bucketName} in region ${region}`);
657
968
  const createBucketParams = { Bucket: bucketName };
658
969
  if (region !== "us-east-1") createBucketParams.CreateBucketConfiguration = { LocationConstraint: region };
659
970
  await s3Client.send(new CreateBucketCommand(createBucketParams));
660
971
  logger.info(`✓ Created S3 bucket: ${bucketName}`);
661
972
  }
662
- logger.debug("Enabling bucket versioning...");
663
- await s3Client.send(new PutBucketVersioningCommand({
664
- Bucket: bucketName,
665
- VersioningConfiguration: { Status: "Enabled" }
666
- }));
667
- logger.info("✓ Enabled bucket versioning");
668
- logger.debug("Enabling bucket encryption...");
669
- await s3Client.send(new PutBucketEncryptionCommand({
670
- Bucket: bucketName,
671
- ServerSideEncryptionConfiguration: { Rules: [{
672
- ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
673
- BucketKeyEnabled: true
674
- }] }
675
- }));
676
- logger.info("✓ Enabled bucket encryption (AES-256)");
677
- logger.debug("Setting bucket policy...");
678
- const bucketPolicy = {
679
- Version: "2012-10-17",
680
- Statement: [{
681
- Sid: "DenyExternalAccess",
682
- Effect: "Deny",
683
- Principal: "*",
684
- Action: "s3:*",
685
- Resource: [`arn:aws:s3:::${bucketName}`, `arn:aws:s3:::${bucketName}/*`],
686
- Condition: { StringNotEquals: { "aws:PrincipalAccount": accountId } }
687
- }]
688
- };
689
- await s3Client.send(new PutBucketPolicyCommand({
690
- Bucket: bucketName,
691
- Policy: JSON.stringify(bucketPolicy)
692
- }));
693
- logger.info("✓ Set bucket policy (deny external access)");
973
+ if (!skipStateBucketConfig) {
974
+ logger.debug("Enabling bucket versioning...");
975
+ await s3Client.send(new PutBucketVersioningCommand({
976
+ Bucket: bucketName,
977
+ VersioningConfiguration: { Status: "Enabled" }
978
+ }));
979
+ logger.info(" Enabled bucket versioning");
980
+ logger.debug("Enabling bucket encryption...");
981
+ await s3Client.send(new PutBucketEncryptionCommand({
982
+ Bucket: bucketName,
983
+ ServerSideEncryptionConfiguration: { Rules: [{
984
+ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
985
+ BucketKeyEnabled: true
986
+ }] }
987
+ }));
988
+ logger.info(" Enabled bucket encryption (AES-256)");
989
+ logger.debug("Setting bucket policy...");
990
+ const bucketPolicy = {
991
+ Version: "2012-10-17",
992
+ Statement: [{
993
+ Sid: "DenyExternalAccess",
994
+ Effect: "Deny",
995
+ Principal: "*",
996
+ Action: "s3:*",
997
+ Resource: [`arn:aws:s3:::${bucketName}`, `arn:aws:s3:::${bucketName}/*`],
998
+ Condition: { StringNotEquals: { "aws:PrincipalAccount": accountId } }
999
+ }]
1000
+ };
1001
+ await s3Client.send(new PutBucketPolicyCommand({
1002
+ Bucket: bucketName,
1003
+ Policy: JSON.stringify(bucketPolicy)
1004
+ }));
1005
+ logger.info("✓ Set bucket policy (deny external access)");
1006
+ }
1007
+ let assetStorage;
1008
+ if (options.assets) {
1009
+ logger.info("\nSetting up cdkd asset storage...");
1010
+ const ecrClient = new ECRClient({
1011
+ region,
1012
+ ...options.profile && { profile: options.profile }
1013
+ });
1014
+ const markerS3Client = new S3Client({
1015
+ region,
1016
+ ...options.profile && { profile: options.profile }
1017
+ });
1018
+ const stateBackend = new S3StateBackend(markerS3Client, {
1019
+ bucket: bucketName,
1020
+ prefix: "cdkd"
1021
+ }, {
1022
+ region,
1023
+ ...options.profile && { profile: options.profile }
1024
+ });
1025
+ try {
1026
+ assetStorage = await ensureAssetStorage({
1027
+ s3Client,
1028
+ ecrClient,
1029
+ stateBackend,
1030
+ accountId,
1031
+ region,
1032
+ force: options.force
1033
+ });
1034
+ } finally {
1035
+ ecrClient.destroy();
1036
+ markerS3Client.destroy();
1037
+ }
1038
+ } else logger.info("\n--no-assets specified — skipping cdkd asset storage. Deploys in this region keep publishing assets to the CDK bootstrap bucket/repo.");
694
1039
  logger.info("\n✓ Bootstrap completed successfully");
695
1040
  logger.info(`\nState bucket: ${bucketName}`);
1041
+ if (assetStorage) {
1042
+ logger.info(`Asset bucket: ${assetStorage.assetBucket}`);
1043
+ logger.info(`Container-asset repository: ${assetStorage.containerRepo}`);
1044
+ }
696
1045
  logger.info(`Region: ${region}`);
1046
+ if (assetStorage) logger.info(`\ncdkd asset storage is now ON for region ${region}: deploys in this region publish assets to the cdkd-owned bucket/repo (out of 'cdk gc' reach) instead of the CDK bootstrap storage. The first deploy of each existing stack with assets will show a one-time UPDATE re-pointing asset references — content is identical, no replacement.`);
697
1047
  logger.info("\nYou can now use cdkd deploy with:");
698
1048
  logger.info(` --state-bucket ${bucketName}`);
699
1049
  logger.info(` --region ${region}`);
@@ -705,7 +1055,7 @@ async function bootstrapCommand(options) {
705
1055
  * Create bootstrap command
706
1056
  */
707
1057
  function createBootstrapCommand() {
708
- const cmd = new Command("bootstrap").description("Bootstrap cdkd by creating required S3 bucket for state management").option("--state-bucket <bucket>", "Name of S3 bucket to create for state storage (default: cdkd-state-{accountId})").option("--force", "Force reconfiguration of existing bucket", false).addOption(new Option("--region <region>", "AWS region in which to create the state bucket (defaults to AWS_REGION env or us-east-1)")).action(withErrorHandling(bootstrapCommand));
1058
+ const cmd = new Command("bootstrap").description("Bootstrap cdkd by creating the S3 state bucket plus cdkd-owned asset storage (asset bucket + container-asset ECR repo) for the region").option("--state-bucket <bucket>", "Name of S3 bucket to create for state storage (default: cdkd-state-{accountId})").option("--force", "Force reconfiguration of existing bucket", false).option("--no-assets", "Skip cdkd asset storage (asset bucket / ECR repo / marker) — deploys keep publishing assets to the CDK bootstrap bucket/repo").addOption(new Option("--region <region>", "AWS region in which to create the state bucket (defaults to AWS_REGION env or us-east-1)")).action(withErrorHandling(bootstrapCommand));
709
1059
  commonOptions.forEach((opt) => cmd.addOption(opt));
710
1060
  return cmd;
711
1061
  }
@@ -1445,7 +1795,7 @@ const FLUSH_INTERVAL_MS = 2e3;
1445
1795
  const FLUSH_EVENT_THRESHOLD = 50;
1446
1796
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
1447
1797
  function getCdkdVersion() {
1448
- return "0.231.12";
1798
+ return "0.232.0";
1449
1799
  }
1450
1800
  /**
1451
1801
  * Generate a time-sortable unique run id, e.g.
@@ -37644,6 +37994,7 @@ async function deployCommand(stacks, options) {
37644
37994
  const accountId = (await stsClient.send(new GetCallerIdentityCommand({}))).Account;
37645
37995
  stsClient.destroy();
37646
37996
  const assetPublisher = new AssetPublisher();
37997
+ const assetModeResolver = new AssetModeResolver(preflightStateBackend, accountId, { ...options.profile && { profile: options.profile } });
37647
37998
  const stateConfig = {
37648
37999
  bucket: stateBucket,
37649
38000
  prefix: options.statePrefix
@@ -37660,17 +38011,24 @@ async function deployCommand(stacks, options) {
37660
38011
  for (const stack of targetStacks) {
37661
38012
  const stackNodeId = `stack:${stack.stackName}`;
37662
38013
  const stackDeps = /* @__PURE__ */ new Set();
37663
- if (!options.skipAssets && stack.assetManifestPath) try {
38014
+ if (!options.skipAssets && stack.assetManifestPath) {
37664
38015
  const assetRegion = stack.region || baseRegion;
37665
- const nodeIds = assetPublisher.addAssetsToGraph(workGraph, stack.assetManifestPath, {
37666
- accountId,
37667
- region: assetRegion,
37668
- ...options.profile && { profile: options.profile },
37669
- nodePrefix: `${stack.stackName}:`
37670
- });
38016
+ let nodeIds = [];
38017
+ try {
38018
+ nodeIds = assetPublisher.addAssetsToGraph(workGraph, stack.assetManifestPath, {
38019
+ accountId,
38020
+ region: assetRegion,
38021
+ ...options.profile && { profile: options.profile },
38022
+ nodePrefix: `${stack.stackName}:`
38023
+ });
38024
+ } catch (error) {
38025
+ if (error.code !== "ENOENT") throw error;
38026
+ }
38027
+ if (nodeIds.length > 0) {
38028
+ const assetMode = await assetModeResolver.resolve(assetRegion);
38029
+ logger.debug(`Asset mode for region ${assetRegion}: ${assetMode.mode} (stack ${stack.stackName})`);
38030
+ }
37671
38031
  for (const id of nodeIds) stackDeps.add(id);
37672
- } catch (error) {
37673
- if (error.code !== "ENOENT") throw error;
37674
38032
  }
37675
38033
  for (const depName of effectiveStackDeps(stack.stackName, stack.dependencyNames)) if (stackMap.has(depName)) stackDeps.add(`stack:${depName}`);
37676
38034
  workGraph.addNode({
@@ -45188,6 +45546,45 @@ async function readSchemaVersion(awsClients, bucket, keys) {
45188
45546
  }
45189
45547
  }
45190
45548
  /**
45549
+ * List every region's bootstrap marker under `cdkd-bootstrap/` in the state
45550
+ * bucket. Malformed markers are skipped with a warning — `state info` is a
45551
+ * cosmetic command and should not crash on an unexpected payload (deploy
45552
+ * hard-errors on the same marker instead).
45553
+ */
45554
+ async function listAssetStorageMarkers(awsClients, bucket) {
45555
+ const logger = getLogger();
45556
+ const entries = [];
45557
+ let continuationToken;
45558
+ const keys = [];
45559
+ do {
45560
+ const resp = await awsClients.s3.send(new ListObjectsV2Command({
45561
+ Bucket: bucket,
45562
+ Prefix: BOOTSTRAP_MARKER_PREFIX,
45563
+ ...continuationToken && { ContinuationToken: continuationToken }
45564
+ }));
45565
+ for (const obj of resp.Contents ?? []) if (typeof obj.Key === "string" && obj.Key.startsWith("cdkd-bootstrap/") && obj.Key.endsWith(".json")) keys.push(obj.Key);
45566
+ continuationToken = resp.NextContinuationToken;
45567
+ } while (continuationToken);
45568
+ for (const key of keys) {
45569
+ const region = key.slice(BOOTSTRAP_MARKER_PREFIX.length, -5);
45570
+ try {
45571
+ const marker = parseBootstrapMarker(await (await awsClients.s3.send(new GetObjectCommand({
45572
+ Bucket: bucket,
45573
+ Key: key
45574
+ }))).Body?.transformToString() ?? "", key);
45575
+ entries.push({
45576
+ region,
45577
+ assetBucket: marker.assetBucket,
45578
+ containerRepo: marker.containerRepo,
45579
+ createdAt: marker.createdAt
45580
+ });
45581
+ } catch (error) {
45582
+ logger.warn(`Skipping malformed/unreadable bootstrap marker '${key}': ${error.message}`);
45583
+ }
45584
+ }
45585
+ return entries.sort((a, b) => a.region.localeCompare(b.region));
45586
+ }
45587
+ /**
45191
45588
  * `cdkd state info` command implementation.
45192
45589
  *
45193
45590
  * Prints the state-bucket information that used to appear as a banner on
@@ -45229,6 +45626,7 @@ async function stateInfoCommand(options) {
45229
45626
  const detectedRegion = await detectBucketRegion(awsClients, bucket);
45230
45627
  const stateFileKeys = await listStateFileKeys(awsClients, bucket, prefix);
45231
45628
  const schemaVersion = await readSchemaVersion(awsClients, bucket, stateFileKeys);
45629
+ const assetStorage = await listAssetStorageMarkers(awsClients, bucket);
45232
45630
  if (options.json) {
45233
45631
  const json = {
45234
45632
  bucket,
@@ -45236,7 +45634,8 @@ async function stateInfoCommand(options) {
45236
45634
  regionSource: detectedRegion ? "auto-detected" : "unknown",
45237
45635
  bucketSource: resolved.source,
45238
45636
  schemaVersion,
45239
- stackCount: stateFileKeys.length
45637
+ stackCount: stateFileKeys.length,
45638
+ assetStorage
45240
45639
  };
45241
45640
  process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
45242
45641
  return;
@@ -45248,6 +45647,11 @@ async function stateInfoCommand(options) {
45248
45647
  lines.push(`Source: ${formatBucketSource(resolved.source)}`);
45249
45648
  lines.push(`Schema version: ${schemaVersion}`);
45250
45649
  lines.push(`Stacks: ${stateFileKeys.length}`);
45650
+ if (assetStorage.length === 0) lines.push("Asset storage: legacy (CDK bootstrap) — run cdkd bootstrap to opt in");
45651
+ else {
45652
+ lines.push(`Asset storage: cdkd-assets mode in ${assetStorage.length} region(s)`);
45653
+ for (const entry of assetStorage) lines.push(` ${entry.region}: ${entry.assetBucket} / ${entry.containerRepo}`);
45654
+ }
45251
45655
  process.stdout.write(`${lines.join("\n")}\n`);
45252
45656
  } finally {
45253
45657
  awsClients.destroy();
@@ -55779,7 +56183,7 @@ function reorderArgs(argv) {
55779
56183
  async function main() {
55780
56184
  installPipeCloseHandler();
55781
56185
  const program = new Command();
55782
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.231.12");
56186
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.232.0");
55783
56187
  program.addCommand(createBootstrapCommand());
55784
56188
  program.addCommand(createSynthCommand());
55785
56189
  program.addCommand(createListCommand());