@go-to-k/cdkd 0.97.0 → 0.98.1
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 +25 -0
- package/dist/cli.js +414 -8
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-Cl7v7Ml5.js → deploy-engine-D44ADMVs.js} +164 -41
- package/dist/deploy-engine-D44ADMVs.js.map +1 -0
- package/dist/go-to-k-cdkd-0.98.1.tgz +0 -0
- package/dist/index.d.ts +54 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-Cl7v7Ml5.js.map +0 -1
- package/dist/go-to-k-cdkd-0.97.0.tgz +0 -0
package/README.md
CHANGED
|
@@ -335,6 +335,31 @@ full reference. For per-resource-type provisioning support (SDK Providers
|
|
|
335
335
|
vs Cloud Control API fallback), see
|
|
336
336
|
**[docs/supported-resources.md](docs/supported-resources.md)**.
|
|
337
337
|
|
|
338
|
+
### Cross-stack references — strong vs weak
|
|
339
|
+
|
|
340
|
+
`Fn::ImportValue` is a **strong reference**: `cdkd destroy <producer>`
|
|
341
|
+
refuses to delete a stack while any other stack still imports one of
|
|
342
|
+
its exports via `Fn::ImportValue`. Matches CloudFormation's behavior.
|
|
343
|
+
The error message names every offending consumer and points at the
|
|
344
|
+
two valid resolution paths (destroy the consumer first, or remove
|
|
345
|
+
the `Fn::ImportValue` from the consumer's template and redeploy).
|
|
346
|
+
|
|
347
|
+
`Fn::GetStackOutput` (cdkd-specific) is a **weak reference**: the
|
|
348
|
+
producer stays deletable independently of consumers. Use it when you
|
|
349
|
+
intentionally want decoupled lifecycles (cross-region / cross-stage /
|
|
350
|
+
staging environments).
|
|
351
|
+
|
|
352
|
+
A persistent per-region exports index at
|
|
353
|
+
`s3://{state-bucket}/cdkd/_index/{region}/exports.json` makes
|
|
354
|
+
`Fn::ImportValue` resolution O(1) at scale (200-stack environments
|
|
355
|
+
resolve in ~100ms vs minutes with the pre-#343 per-resolve scan).
|
|
356
|
+
The index is a derived view rebuilt from `state.json` on demand —
|
|
357
|
+
state.json remains the canonical source of truth, and strong-reference
|
|
358
|
+
safety checks scan it directly rather than trusting the index.
|
|
359
|
+
|
|
360
|
+
See **[docs/cross-stack-references.md](docs/cross-stack-references.md)**
|
|
361
|
+
for the full design (schema v4, lifecycle, locking, failure modes).
|
|
362
|
+
|
|
338
363
|
## Rollback behavior
|
|
339
364
|
|
|
340
365
|
When a deploy fails mid-stack (e.g. a resource hits a validation error
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-CuHRHcyW.js";
|
|
3
|
-
import { A as resolveCaptureObservedState, C as stringifyValue, D as getDefaultStateBucketName, E as Synthesizer, F as AssemblyReader, G as ProvisioningError, H as LocalInvokeBuildError, J as RouteDiscoveryError, K as ResourceTimeoutError, L as resolveBucketRegion, M as resolveStateBucketWithDefault, N as resolveStateBucketWithDefaultAndSource, O as getLegacyStateBucketName, P as warnDeprecatedNoPrefixCliFlag, S as AssetPublisher, T as buildDockerImage, W as PartialFailureError,
|
|
3
|
+
import { A as resolveCaptureObservedState, C as stringifyValue, D as getDefaultStateBucketName, E as Synthesizer, F as AssemblyReader, G as ProvisioningError, H as LocalInvokeBuildError, J as RouteDiscoveryError, K as ResourceTimeoutError, L as resolveBucketRegion, M as resolveStateBucketWithDefault, N as resolveStateBucketWithDefaultAndSource, O as getLegacyStateBucketName, P as warnDeprecatedNoPrefixCliFlag, S as AssetPublisher, T as buildDockerImage, W as PartialFailureError, X as StackTerminationProtectionError, Y as StackHasActiveImportsError, _ as DiffCalculator, a as withRetry, b as LockManager, c as collectInlinePolicyNamesManagedBySiblings, ct as PATTERN_B_NAME_PROPERTIES, d as normalizeAwsTagsToCfn, dt as generateResourceNameWithFallback, f as resolveExplicitPhysicalId, ft as withSkipPrefix, g as IntrinsicFunctionResolver, h as assertRegionMatch, i as withResourceDeadline, it as getLogger, j as resolveSkipPrefix, k as resolveApp, l as CDK_PATH_TAG, lt as PATTERN_B_RESOURCE_TYPES, m as CloudControlProvider, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as withErrorHandling, o as IMPLICIT_DELETE_DEPENDENCIES, ot as runStackBuffered, p as ProviderRegistry, pt as withStackName, q as ResourceUpdateNotSupportedError, r as DeployEngine, s as IAMRoleProvider, st as getLiveRenderer, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as normalizeAwsError, u as matchesCdkPath, ut as generateResourceName, v as DagBuilder, w as WorkGraph, x as S3StateBackend, y as TemplateParser, z as CdkdError } from "./deploy-engine-D44ADMVs.js";
|
|
4
4
|
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
-
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client } from "@aws-sdk/client-s3";
|
|
5
|
+
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
6
6
|
import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
|
|
7
7
|
import { CreateQueueCommand, DeleteQueueCommand, GetQueueAttributesCommand, GetQueueUrlCommand, ListQueueTagsCommand, ListQueuesCommand, QueueDoesNotExist, SQSClient, SetQueueAttributesCommand, TagQueueCommand, UntagQueueCommand } from "@aws-sdk/client-sqs";
|
|
8
8
|
import { CreateTopicCommand, DeleteTopicCommand, GetSubscriptionAttributesCommand, GetTopicAttributesCommand, ListTagsForResourceCommand, ListTopicsCommand, NotFoundException, SNSClient, SetTopicAttributesCommand, SubscribeCommand, TagResourceCommand, UnsubscribeCommand, UntagResourceCommand } from "@aws-sdk/client-sns";
|
|
@@ -870,6 +870,357 @@ function createListCommand() {
|
|
|
870
870
|
return cmd;
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/state/export-index-store.ts
|
|
875
|
+
/**
|
|
876
|
+
* Export index store — persistent global index of `Fn::ImportValue`
|
|
877
|
+
* resolvable exports across all cdkd-managed stacks in a region.
|
|
878
|
+
*
|
|
879
|
+
* Concept: CFn's `cloudformation:ListExports` API is internally backed by
|
|
880
|
+
* an index that lets the producer's Output be looked up by export name in
|
|
881
|
+
* O(1) regardless of how many stacks exist. cdkd mirrors that pattern via
|
|
882
|
+
* `s3://{bucket}/{prefix}/_index/{region}/exports.json` so the resolver
|
|
883
|
+
* doesn't pay the O(N) state-bucket scan on every `Fn::ImportValue`.
|
|
884
|
+
*
|
|
885
|
+
* Roles:
|
|
886
|
+
* - **state.json** (per-stack) is the canonical source of truth for a
|
|
887
|
+
* stack's outputs + imports. Always written / read with optimistic
|
|
888
|
+
* locking.
|
|
889
|
+
* - **exports.json** (per-region, in this module) is a DERIVED VIEW used
|
|
890
|
+
* only as a perf hint. It can be rebuilt from state.json at any time
|
|
891
|
+
* and is allowed to drift briefly. Strong-reference safety checks
|
|
892
|
+
* never trust the index — they always re-scan state.json.
|
|
893
|
+
*
|
|
894
|
+
* Failure mode summary:
|
|
895
|
+
* - Index missing → auto-rebuild from state.json on first access.
|
|
896
|
+
* - Index corrupt → log warning, auto-rebuild.
|
|
897
|
+
* - Index stale (post-deploy index update failed) → next resolve's miss
|
|
898
|
+
* triggers fallback scan; if found, the entry is patched into the
|
|
899
|
+
* index incrementally.
|
|
900
|
+
* - Two cdkd processes writing concurrently → S3 If-Match optimistic
|
|
901
|
+
* lock + bounded retry. After exhaustion, the writer logs warn and
|
|
902
|
+
* continues; the index becomes stale until the next deploy/destroy
|
|
903
|
+
* updates it.
|
|
904
|
+
*/
|
|
905
|
+
/** Schema version for the exports index file. Separate from state.json's version. */
|
|
906
|
+
const EXPORT_INDEX_VERSION = 1;
|
|
907
|
+
/**
|
|
908
|
+
* Shallow-deep equality on the two `name → ExportIndexEntry` maps used
|
|
909
|
+
* to detect no-op writes in `applyStackUpdate`. Values are compared via
|
|
910
|
+
* JSON.stringify (Output values are always JSON-serializable).
|
|
911
|
+
*/
|
|
912
|
+
function mapsEqual(a, b) {
|
|
913
|
+
if (a.size !== b.size) return false;
|
|
914
|
+
for (const [name, entry] of a) {
|
|
915
|
+
const other = b.get(name);
|
|
916
|
+
if (!other) return false;
|
|
917
|
+
if (other.producerStack !== entry.producerStack || other.producerRegion !== entry.producerRegion) return false;
|
|
918
|
+
if (other.value !== entry.value) {
|
|
919
|
+
if (JSON.stringify(other.value) !== JSON.stringify(entry.value)) return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
const DEFAULT_OPTIONS = {
|
|
925
|
+
maxWriteRetries: 5,
|
|
926
|
+
initialBackoffMs: 100,
|
|
927
|
+
maxBackoffMs: 1e3
|
|
928
|
+
};
|
|
929
|
+
var ExportIndexStore = class {
|
|
930
|
+
logger = getLogger().child("ExportIndexStore");
|
|
931
|
+
s3Client;
|
|
932
|
+
bucket;
|
|
933
|
+
prefix;
|
|
934
|
+
region;
|
|
935
|
+
stateBackend;
|
|
936
|
+
loadState = { kind: "unloaded" };
|
|
937
|
+
opts;
|
|
938
|
+
/**
|
|
939
|
+
* In-process serializer for write paths (`updateForStack`,
|
|
940
|
+
* `patchEntry`, `removeStack`). The S3 `If-Match` etag prevents
|
|
941
|
+
* cross-process data loss, but within ONE cdkd process the
|
|
942
|
+
* default `cdkd deploy --all --stack-concurrency > 1` topology
|
|
943
|
+
* lets multiple per-stack writes race on the same etag — they
|
|
944
|
+
* would all read the same loaded snapshot, all attempt to write
|
|
945
|
+
* with that etag, all but one fail with PreconditionFailed, all
|
|
946
|
+
* retry, and burn through the bounded retry budget for no good
|
|
947
|
+
* reason. Serializing write paths via this chained promise lets
|
|
948
|
+
* the etag race only fire across processes (cross-app concurrency)
|
|
949
|
+
* where it actually matters. Reads (`lookup`) remain unsynchronized.
|
|
950
|
+
*/
|
|
951
|
+
writeChain = Promise.resolve();
|
|
952
|
+
constructor(s3Client, bucket, prefix, region, stateBackend, opts = {}) {
|
|
953
|
+
this.s3Client = s3Client;
|
|
954
|
+
this.bucket = bucket;
|
|
955
|
+
this.prefix = prefix;
|
|
956
|
+
this.region = region;
|
|
957
|
+
this.stateBackend = stateBackend;
|
|
958
|
+
this.opts = {
|
|
959
|
+
...DEFAULT_OPTIONS,
|
|
960
|
+
...opts
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** S3 key for this region's index file. */
|
|
964
|
+
indexKey() {
|
|
965
|
+
return `${this.prefix}/_index/${this.region}/exports.json`;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Look up an exported value by name. Returns the cached entry on hit,
|
|
969
|
+
* or `undefined` on miss. The caller is responsible for falling back
|
|
970
|
+
* to a state.json scan and (if found) patching the index via
|
|
971
|
+
* {@link patchEntry}.
|
|
972
|
+
*/
|
|
973
|
+
async lookup(exportName) {
|
|
974
|
+
await this.ensureLoaded();
|
|
975
|
+
if (this.loadState.kind !== "loaded") return;
|
|
976
|
+
return this.loadState.entries.get(exportName);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Replace all entries for `(stackName, producerRegion)` with the
|
|
980
|
+
* supplied `outputs`. Used after a successful deploy save. Writes
|
|
981
|
+
* the updated index to S3 under an If-Match optimistic lock,
|
|
982
|
+
* retrying on conflict.
|
|
983
|
+
*
|
|
984
|
+
* If `outputs` is empty, every entry currently owned by this stack
|
|
985
|
+
* in this region is removed.
|
|
986
|
+
*
|
|
987
|
+
* Best-effort: on persistent retry exhaustion the in-memory map is
|
|
988
|
+
* still updated locally (so this session sees a consistent view) and
|
|
989
|
+
* a warning is logged. The on-disk index will be repaired by the
|
|
990
|
+
* next successful update or by a rebuild on miss.
|
|
991
|
+
*/
|
|
992
|
+
async updateForStack(stackName, producerRegion, outputs) {
|
|
993
|
+
await this.enqueueWrite("update", () => this.applyStackUpdate(stackName, producerRegion, outputs));
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Patch a single entry into the index after a `lookup` miss fell back
|
|
997
|
+
* to a state.json scan and found the value. Lightweight write that
|
|
998
|
+
* does NOT require a full rebuild.
|
|
999
|
+
*/
|
|
1000
|
+
async patchEntry(exportName, entry) {
|
|
1001
|
+
await this.enqueueWrite("patch", () => this.applyPatch(exportName, entry));
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Drop all entries owned by `(stackName, producerRegion)`. Used after
|
|
1005
|
+
* a successful destroy. Same retry / best-effort semantics as
|
|
1006
|
+
* `updateForStack`. Filtering by both stack AND region is symmetric
|
|
1007
|
+
* with the update path so a stack that was re-deployed to a new
|
|
1008
|
+
* region keeps its old-region entries (the user must destroy in the
|
|
1009
|
+
* old region too to drop them).
|
|
1010
|
+
*/
|
|
1011
|
+
async removeStack(stackName, producerRegion) {
|
|
1012
|
+
await this.enqueueWrite("remove", () => this.applyRemoveStack(stackName, producerRegion));
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Serialize write paths within a single process. Chains every write
|
|
1016
|
+
* onto a tail Promise so two concurrent `updateForStack` calls don't
|
|
1017
|
+
* race on the same etag inside the same cdkd. The S3 If-Match retry
|
|
1018
|
+
* remains as cross-process protection.
|
|
1019
|
+
*/
|
|
1020
|
+
async enqueueWrite(label, op) {
|
|
1021
|
+
const next = this.writeChain.then(() => this.runWithRetry(label, op));
|
|
1022
|
+
this.writeChain = next.catch(() => {});
|
|
1023
|
+
return next;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Force a rebuild from state.json files, overwriting whatever is in
|
|
1027
|
+
* the on-disk index. Useful for recovery and for tests.
|
|
1028
|
+
*/
|
|
1029
|
+
async rebuild() {
|
|
1030
|
+
const entries = await this.rebuildFromStateBackend();
|
|
1031
|
+
const next = {
|
|
1032
|
+
indexVersion: 1,
|
|
1033
|
+
region: this.region,
|
|
1034
|
+
exports: Object.fromEntries(entries),
|
|
1035
|
+
lastModified: Date.now()
|
|
1036
|
+
};
|
|
1037
|
+
const etag = await this.writeIndex(next, void 0);
|
|
1038
|
+
this.loadState = {
|
|
1039
|
+
kind: "loaded",
|
|
1040
|
+
etag,
|
|
1041
|
+
entries
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
async ensureLoaded() {
|
|
1045
|
+
if (this.loadState.kind === "loaded") return;
|
|
1046
|
+
if (this.loadState.kind === "loading") {
|
|
1047
|
+
await this.loadState.promise;
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const promise = this.doLoad();
|
|
1051
|
+
this.loadState = {
|
|
1052
|
+
kind: "loading",
|
|
1053
|
+
promise
|
|
1054
|
+
};
|
|
1055
|
+
await promise;
|
|
1056
|
+
}
|
|
1057
|
+
async doLoad() {
|
|
1058
|
+
try {
|
|
1059
|
+
const raw = await this.readIndexRaw();
|
|
1060
|
+
if (raw === null) {
|
|
1061
|
+
this.logger.info("Exports index missing; rebuilding from state.json files");
|
|
1062
|
+
await this.rebuild();
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const { body, etag } = raw;
|
|
1066
|
+
let parsed;
|
|
1067
|
+
try {
|
|
1068
|
+
parsed = JSON.parse(body);
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
this.logger.warn(`Exports index corrupt (${err instanceof Error ? err.message : String(err)}); rebuilding from state.json files`);
|
|
1071
|
+
await this.rebuild();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (typeof parsed.indexVersion !== "number" || parsed.indexVersion > 1) throw new Error(`Exports index uses indexVersion ${String(parsed.indexVersion)} which is newer than this cdkd binary supports (max ${1}). Upgrade cdkd.`);
|
|
1075
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1076
|
+
for (const [name, entry] of Object.entries(parsed.exports ?? {})) entries.set(name, entry);
|
|
1077
|
+
this.loadState = {
|
|
1078
|
+
kind: "loaded",
|
|
1079
|
+
etag,
|
|
1080
|
+
entries
|
|
1081
|
+
};
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
this.loadState = { kind: "unloaded" };
|
|
1084
|
+
throw err;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async readIndexRaw() {
|
|
1088
|
+
try {
|
|
1089
|
+
const response = await this.s3Client.send(new GetObjectCommand({
|
|
1090
|
+
Bucket: this.bucket,
|
|
1091
|
+
Key: this.indexKey()
|
|
1092
|
+
}));
|
|
1093
|
+
return {
|
|
1094
|
+
body: await response.Body?.transformToString() ?? "",
|
|
1095
|
+
etag: response.ETag
|
|
1096
|
+
};
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
if (this.isNoSuchKey(err)) return null;
|
|
1099
|
+
throw err;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async writeIndex(next, expectedEtag) {
|
|
1103
|
+
const body = JSON.stringify(next, null, 2);
|
|
1104
|
+
return (await this.s3Client.send(new PutObjectCommand({
|
|
1105
|
+
Bucket: this.bucket,
|
|
1106
|
+
Key: this.indexKey(),
|
|
1107
|
+
Body: body,
|
|
1108
|
+
ContentLength: Buffer.byteLength(body),
|
|
1109
|
+
ContentType: "application/json",
|
|
1110
|
+
...expectedEtag && { IfMatch: expectedEtag }
|
|
1111
|
+
}))).ETag;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Re-read state.json for every stack in the bucket and assemble a
|
|
1115
|
+
* fresh `exportName → entry` map. Region-scoped — only state files
|
|
1116
|
+
* in this index's region contribute.
|
|
1117
|
+
*/
|
|
1118
|
+
async rebuildFromStateBackend() {
|
|
1119
|
+
const inRegion = (await this.stateBackend.listStacks()).filter((ref) => ref.region === this.region);
|
|
1120
|
+
this.logger.debug(`Rebuilding exports index for region '${this.region}' from ${inRegion.length} stack state file(s)`);
|
|
1121
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1122
|
+
const results = await Promise.all(inRegion.map(async (ref) => {
|
|
1123
|
+
try {
|
|
1124
|
+
return {
|
|
1125
|
+
ref,
|
|
1126
|
+
state: (await this.stateBackend.getState(ref.stackName, ref.region ?? this.region))?.state
|
|
1127
|
+
};
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
this.logger.warn(`Failed to read state for ${ref.stackName} (${ref.region ?? ""}) during index rebuild: ${err instanceof Error ? err.message : String(err)}`);
|
|
1130
|
+
return {
|
|
1131
|
+
ref,
|
|
1132
|
+
state: null
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
}));
|
|
1136
|
+
for (const { ref, state } of results) {
|
|
1137
|
+
if (!state || !state.outputs) continue;
|
|
1138
|
+
const region = ref.region ?? this.region;
|
|
1139
|
+
for (const [name, value] of Object.entries(state.outputs)) entries.set(name, {
|
|
1140
|
+
value,
|
|
1141
|
+
producerStack: ref.stackName,
|
|
1142
|
+
producerRegion: region
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
return entries;
|
|
1146
|
+
}
|
|
1147
|
+
async applyStackUpdate(stackName, producerRegion, outputs) {
|
|
1148
|
+
await this.ensureLoaded();
|
|
1149
|
+
if (this.loadState.kind !== "loaded") return;
|
|
1150
|
+
const next = new Map(this.loadState.entries);
|
|
1151
|
+
for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) next.delete(name);
|
|
1152
|
+
for (const [name, value] of Object.entries(outputs)) next.set(name, {
|
|
1153
|
+
value,
|
|
1154
|
+
producerStack: stackName,
|
|
1155
|
+
producerRegion
|
|
1156
|
+
});
|
|
1157
|
+
if (mapsEqual(this.loadState.entries, next)) return;
|
|
1158
|
+
await this.persist(next);
|
|
1159
|
+
}
|
|
1160
|
+
async applyPatch(exportName, entry) {
|
|
1161
|
+
await this.ensureLoaded();
|
|
1162
|
+
if (this.loadState.kind !== "loaded") return;
|
|
1163
|
+
const next = new Map(this.loadState.entries);
|
|
1164
|
+
next.set(exportName, entry);
|
|
1165
|
+
await this.persist(next);
|
|
1166
|
+
}
|
|
1167
|
+
async applyRemoveStack(stackName, producerRegion) {
|
|
1168
|
+
await this.ensureLoaded();
|
|
1169
|
+
if (this.loadState.kind !== "loaded") return;
|
|
1170
|
+
const next = new Map(this.loadState.entries);
|
|
1171
|
+
let changed = false;
|
|
1172
|
+
for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) {
|
|
1173
|
+
next.delete(name);
|
|
1174
|
+
changed = true;
|
|
1175
|
+
}
|
|
1176
|
+
if (!changed) return;
|
|
1177
|
+
await this.persist(next);
|
|
1178
|
+
}
|
|
1179
|
+
async persist(entries) {
|
|
1180
|
+
if (this.loadState.kind !== "loaded") return;
|
|
1181
|
+
const file = {
|
|
1182
|
+
indexVersion: 1,
|
|
1183
|
+
region: this.region,
|
|
1184
|
+
exports: Object.fromEntries(entries),
|
|
1185
|
+
lastModified: Date.now()
|
|
1186
|
+
};
|
|
1187
|
+
const etag = await this.writeIndex(file, this.loadState.etag);
|
|
1188
|
+
this.loadState = {
|
|
1189
|
+
kind: "loaded",
|
|
1190
|
+
etag,
|
|
1191
|
+
entries
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
async runWithRetry(label, op) {
|
|
1195
|
+
let lastErr;
|
|
1196
|
+
for (let attempt = 0; attempt < this.opts.maxWriteRetries; attempt++) try {
|
|
1197
|
+
await op();
|
|
1198
|
+
return;
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
lastErr = err;
|
|
1201
|
+
if (this.isPreconditionFailed(err)) {
|
|
1202
|
+
this.loadState = { kind: "unloaded" };
|
|
1203
|
+
const backoff = Math.min(this.opts.initialBackoffMs * 2 ** attempt, this.opts.maxBackoffMs);
|
|
1204
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
this.logger.warn(`Exports index ${label} failed (non-retryable): ${err instanceof Error ? err.message : String(err)}; continuing without index update`);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
this.logger.warn(`Exports index ${label} exhausted ${this.opts.maxWriteRetries} retries due to concurrent writers; continuing without index update. Last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1211
|
+
}
|
|
1212
|
+
isNoSuchKey(err) {
|
|
1213
|
+
if (!err || typeof err !== "object") return false;
|
|
1214
|
+
const e = err;
|
|
1215
|
+
return e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404;
|
|
1216
|
+
}
|
|
1217
|
+
isPreconditionFailed(err) {
|
|
1218
|
+
if (!err || typeof err !== "object") return false;
|
|
1219
|
+
const e = err;
|
|
1220
|
+
return e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412;
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
|
|
873
1224
|
//#endregion
|
|
874
1225
|
//#region src/provisioning/providers/iam-policy-provider.ts
|
|
875
1226
|
/**
|
|
@@ -27302,13 +27653,15 @@ async function deployCommand(stacks, options) {
|
|
|
27302
27653
|
...options.profile && { profile: options.profile }
|
|
27303
27654
|
});
|
|
27304
27655
|
setAwsClients(awsClients);
|
|
27305
|
-
|
|
27656
|
+
const preflightStateBackend = new S3StateBackend(awsClients.s3, {
|
|
27306
27657
|
bucket: stateBucket,
|
|
27307
27658
|
prefix: options.statePrefix
|
|
27308
27659
|
}, {
|
|
27309
27660
|
region,
|
|
27310
27661
|
...options.profile && { profile: options.profile }
|
|
27311
|
-
})
|
|
27662
|
+
});
|
|
27663
|
+
await preflightStateBackend.verifyBucketExists();
|
|
27664
|
+
const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, preflightStateBackend);
|
|
27312
27665
|
let deployInterrupted = false;
|
|
27313
27666
|
const topLevelSigintHandler = () => {
|
|
27314
27667
|
if (deployInterrupted) {
|
|
@@ -27442,7 +27795,7 @@ async function deployCommand(stacks, options) {
|
|
|
27442
27795
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
27443
27796
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
27444
27797
|
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
27445
|
-
}, stackRegion).deploy(stackInfo.stackName, stackInfo.template);
|
|
27798
|
+
}, stackRegion, exportIndexStore).deploy(stackInfo.stackName, stackInfo.template);
|
|
27446
27799
|
logger.info("\nDeployment Summary:");
|
|
27447
27800
|
logger.info(` Stack: ${deployResult.stackName}`);
|
|
27448
27801
|
logger.info(` Created: ${deployResult.created}`);
|
|
@@ -27612,7 +27965,7 @@ async function diffCommand(stacks, options) {
|
|
|
27612
27965
|
region: stackRegion,
|
|
27613
27966
|
resources: {},
|
|
27614
27967
|
outputs: {},
|
|
27615
|
-
version:
|
|
27968
|
+
version: 4,
|
|
27616
27969
|
lastModified: Date.now()
|
|
27617
27970
|
};
|
|
27618
27971
|
if (!stateResult) logger.debug(`No existing state for ${stackInfo.stackName} (${stackRegion})`);
|
|
@@ -28643,6 +28996,11 @@ async function runDestroyForStack(stackName, state, ctx) {
|
|
|
28643
28996
|
result.skippedEmpty = true;
|
|
28644
28997
|
return result;
|
|
28645
28998
|
}
|
|
28999
|
+
const needsStrongRefCheck = !!(state.outputs && Object.keys(state.outputs).length > 0);
|
|
29000
|
+
if (needsStrongRefCheck) {
|
|
29001
|
+
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
29002
|
+
if (consumers.length > 0) throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
29003
|
+
}
|
|
28646
29004
|
logger.info(`\nResources to be deleted (${resourceCount}):`);
|
|
28647
29005
|
for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
|
|
28648
29006
|
const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
|
|
@@ -28685,6 +29043,17 @@ async function runDestroyForStack(stackName, state, ctx) {
|
|
|
28685
29043
|
}
|
|
28686
29044
|
logger.info(`\nAcquiring lock for stack ${stackName}...`);
|
|
28687
29045
|
await ctx.lockManager.acquireLock(stackName, regionForState, void 0, "destroy");
|
|
29046
|
+
if (needsStrongRefCheck) {
|
|
29047
|
+
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
29048
|
+
if (consumers.length > 0) {
|
|
29049
|
+
try {
|
|
29050
|
+
await ctx.lockManager.releaseLock(stackName, regionForState);
|
|
29051
|
+
} catch (releaseErr) {
|
|
29052
|
+
logger.warn(`Failed to release lock after strong-ref refusal: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
|
|
29053
|
+
}
|
|
29054
|
+
throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
29055
|
+
}
|
|
29056
|
+
}
|
|
28688
29057
|
const renderer = getLiveRenderer();
|
|
28689
29058
|
renderer.start();
|
|
28690
29059
|
try {
|
|
@@ -28803,6 +29172,7 @@ async function runDestroyForStack(stackName, state, ctx) {
|
|
|
28803
29172
|
if (result.errorCount === 0) {
|
|
28804
29173
|
await ctx.stateBackend.deleteState(stackName, regionForState);
|
|
28805
29174
|
logger.debug("State deleted");
|
|
29175
|
+
if (ctx.exportIndexStore) await ctx.exportIndexStore.removeStack(stackName, regionForState);
|
|
28806
29176
|
} else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
|
|
28807
29177
|
if (result.errorCount === 0) logger.info(`\n✓ Stack ${stackName} destroyed (${result.deletedCount} deleted, ${result.errorCount} errors)`);
|
|
28808
29178
|
else logger.warn(`\n⚠ Stack ${stackName} partially destroyed (${result.deletedCount} deleted, ${result.errorCount} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up.`);
|
|
@@ -28819,6 +29189,38 @@ async function runDestroyForStack(stackName, state, ctx) {
|
|
|
28819
29189
|
}
|
|
28820
29190
|
return result;
|
|
28821
29191
|
}
|
|
29192
|
+
/**
|
|
29193
|
+
* Strong-reference scan: read every other stack's state.json from the
|
|
29194
|
+
* state bucket and check whether any of its `imports[]` entries names
|
|
29195
|
+
* `producerStack`. Returns the list of offending consumers (possibly
|
|
29196
|
+
* empty).
|
|
29197
|
+
*
|
|
29198
|
+
* NEVER trusts the persistent exports index — a stale index could miss
|
|
29199
|
+
* a freshly-recorded consumer and let a destructive destroy through.
|
|
29200
|
+
* The cost is one `listStacks` + N parallel GETs at destroy time only
|
|
29201
|
+
* (not the deploy hot path), which the user-facing UX rationalizes as
|
|
29202
|
+
* the "destroy is slow OK" trade-off (Issue #343).
|
|
29203
|
+
*/
|
|
29204
|
+
async function scanActiveConsumers(producerStack, producerRegion, ctx) {
|
|
29205
|
+
const refs = await ctx.stateBackend.listStacks();
|
|
29206
|
+
return (await Promise.all(refs.map(async (ref) => {
|
|
29207
|
+
const region = ref.region ?? ctx.baseRegion;
|
|
29208
|
+
if (ref.stackName === producerStack && region === producerRegion) return null;
|
|
29209
|
+
try {
|
|
29210
|
+
const imports = (await ctx.stateBackend.getState(ref.stackName, region))?.state.imports;
|
|
29211
|
+
if (!imports || imports.length === 0) return null;
|
|
29212
|
+
const matches = imports.filter((entry) => entry.sourceStack === producerStack && entry.sourceRegion === producerRegion);
|
|
29213
|
+
if (matches.length === 0) return null;
|
|
29214
|
+
return matches.map((entry) => ({
|
|
29215
|
+
consumerStack: ref.stackName,
|
|
29216
|
+
consumerRegion: region,
|
|
29217
|
+
exportName: entry.exportName
|
|
29218
|
+
}));
|
|
29219
|
+
} catch {
|
|
29220
|
+
return null;
|
|
29221
|
+
}
|
|
29222
|
+
}))).filter((r) => r !== null).flat();
|
|
29223
|
+
}
|
|
28822
29224
|
|
|
28823
29225
|
//#endregion
|
|
28824
29226
|
//#region src/cli/commands/destroy.ts
|
|
@@ -28861,6 +29263,7 @@ async function destroyCommand(stackArgs, options) {
|
|
|
28861
29263
|
});
|
|
28862
29264
|
await stateBackend.verifyBucketExists();
|
|
28863
29265
|
const lockManager = new LockManager(awsClients.s3, stateConfig);
|
|
29266
|
+
const exportIndexStore = new ExportIndexStore(awsClients.s3, stateBucket, options.statePrefix, region, stateBackend);
|
|
28864
29267
|
const providerRegistry = new ProviderRegistry();
|
|
28865
29268
|
registerAllProviders(providerRegistry);
|
|
28866
29269
|
providerRegistry.setCustomResourceResponseBucket(stateBucket);
|
|
@@ -28957,6 +29360,7 @@ async function destroyCommand(stackArgs, options) {
|
|
|
28957
29360
|
stateBucket,
|
|
28958
29361
|
skipConfirmation: options.yes || options.force,
|
|
28959
29362
|
removeProtection: options.removeProtection === true,
|
|
29363
|
+
exportIndexStore,
|
|
28960
29364
|
...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
|
|
28961
29365
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
28962
29366
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
@@ -30231,6 +30635,7 @@ async function setupStateBackend(options) {
|
|
|
30231
30635
|
region,
|
|
30232
30636
|
bucket,
|
|
30233
30637
|
prefix,
|
|
30638
|
+
exportIndexStore: new ExportIndexStore(awsClients.s3, bucket, prefix, region, stateBackend),
|
|
30234
30639
|
dispose: () => awsClients.destroy()
|
|
30235
30640
|
};
|
|
30236
30641
|
}
|
|
@@ -30702,6 +31107,7 @@ async function stateDestroyCommand(stackArgs, options) {
|
|
|
30702
31107
|
stateBucket: setup.bucket,
|
|
30703
31108
|
skipConfirmation: options.yes || options.all === true,
|
|
30704
31109
|
removeProtection: options.removeProtection === true,
|
|
31110
|
+
exportIndexStore: setup.exportIndexStore,
|
|
30705
31111
|
...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
|
|
30706
31112
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
30707
31113
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
@@ -31784,7 +32190,7 @@ function buildStackState(stackName, region, rows, templateParser, template, exis
|
|
|
31784
32190
|
};
|
|
31785
32191
|
}
|
|
31786
32192
|
return {
|
|
31787
|
-
version:
|
|
32193
|
+
version: 4,
|
|
31788
32194
|
stackName,
|
|
31789
32195
|
region,
|
|
31790
32196
|
resources,
|
|
@@ -41837,7 +42243,7 @@ function reorderArgs(argv) {
|
|
|
41837
42243
|
*/
|
|
41838
42244
|
async function main() {
|
|
41839
42245
|
const program = new Command();
|
|
41840
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
42246
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.98.0");
|
|
41841
42247
|
program.addCommand(createBootstrapCommand());
|
|
41842
42248
|
program.addCommand(createSynthCommand());
|
|
41843
42249
|
program.addCommand(createListCommand());
|