@go-to-k/cdkd 0.283.25 → 0.283.27

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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as coerceCfnBoolean, $t as resolveAutoAssetStorage, A as cyan, An as LocalMigrateError, At as loadPublishableAssetManifest, B as findSilentDropProperties, Bn as StackTerminationProtectionError, Bt as buildDenyExternalAccessPolicy, C as makeCanonicalizePropertiesFn, Cn as setAwsClients, Ct as rebuildClientForBucketRegion, D as renderStatefulReason, Dt as WorkGraph, E as isStatefulRecreateTargetSync, Et as stringifyValue, F as IAMRoleProvider, Fn as PartialFailureError, Ft as ensureAssetStorage, G as IntrinsicFunctionResolver, Gn as normalizeAwsError, Gt as runDockerStreaming, H as slowCcOperationTimeoutMs, Ht as formatDockerLoginError, I as collectInlinePolicyNamesManagedBySiblings, In as ProvisioningError, It as getBootstrapMarkerKey, J as refStateLookupFromResource, Jn as isRetryableTransientError, Jt as Synthesizer, K as cfnRefValueFromPhysicalId, Kn as withErrorHandling, Kt as AssetManifestLoader, L as clearOnUpdateRemoval, Ln as ResourceTimeoutError, Lt as parseBootstrapMarker, M as green, Mt as escapeRegExp, N as red, Nn as MissingCdkCliError, Nt as AssetModeResolver, O as formatResourceLine, On as DeployCancelledError, Ot as buildAssetRedirectMap, P as yellow, Pn as NestedStackChildDirectDestroyError, Pt as BOOTSTRAP_MARKER_PREFIX, Q as assertRegionMatch, Qt as resolveApp, R as ProviderRegistry, Rn as ResourceUpdateNotSupportedError, Rt as validateAssetBucketName, S as unsupportedFinalSnapshotError, Sn as resetAwsClients, St as S3StateBackend, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as CdkdError, Tt as AssetPublisher, U as disableInstanceApiTermination, Ut as getDockerCmd, V as CloudControlProvider, Vt as buildDockerImage, W as isTerminationProtectionPropagationError, Wt as runDockerForeground, X as normalizeAwsTagsToCfn, Xn as markNonRetryable, Xt as getDefaultStateBucketName, Y as WAFv2WebACLProvider, Yn as isThrottlingError, Yt as synthesisStatusMessage, Z as resolveExplicitPhysicalId, Zt as getLegacyStateBucketName, _ as buildFinalSnapshotIdentifier, _n as processStackMessages, _t as describeTypeWithThrottleRetry, a as DeploymentEventsStore, an as stateBucketExistenceConfirmed, at as requireConfigObject, b as isFinalSnapshotError, bn as AwsClients, bt as TemplateParser, c as replayFailedOperations, cn as CFN_TEMPLATE_URL_LIMIT, ct as redactSecretsForState, d as deleteSkipReason, dn as uploadCfnTemplate, dt as s3BucketDomainName, en as resolveCaptureObservedState, et as configBooleanRefusal, f as withResourceDeadline, fn as expectedOwnerParam, ft as s3BucketDualStackDomainName, g as PRE_DELETE_SNAPSHOT_TYPES, gn as AssemblyReader, gt as DiffCalculator, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as derivePartitionAndUrlSuffix, ht as applyRoleArnIfSet, i as DeploymentEventsReader, in as resolveUseCdkBootstrapAssets, it as requireConfigArray, j as gray, jn as LocalStartServiceError, jt as rewriteTemplateAssetReferences, k as bold, kn as LocalInvokeBuildError$1, kt as createAssetRedirectResolver, l as replayRollback, ln as MIGRATE_TMP_PREFIX, lt as scrubResourceRecord, m as computeImplicitDeleteEdges, mn as canonicalizeRegion, mt as s3BucketWebsiteUrl, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveStateBucketWithDefault, nt as readConfigString, o as planFailedOps, on as warnDeprecatedNoPrefixCliFlag, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as PARTITION_TABLE, pt as s3BucketRegionalDomainName, q as getAccountInfo, qn as isMarkedNonRetryable, qt as getDockerImageBySourceHash, r as DeployEngine, rn as resolveStateBucketWithDefaultAndSource, rt as replayWarn, s as planRollback, sn as CFN_TEMPLATE_BODY_LIMIT, st as TEMPLATE_SOURCED_READBACK_RULES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveSkipPrefix, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as findLargeInlineResources, ut as s3BucketArn, v as ccRoutedFinalSnapshotError, vt as withRetry, w as extractDeploymentEventError, wt as shouldRetainResource, x as refusesFinalSnapshot, xn as getAwsClients, xt as LockManager, y as createPreDeleteFinalSnapshot, yn as resolveBucketRegion, yt as DagBuilder, z as findActionableSilentDrops, zn as StackHasActiveImportsError, zt as validateContainerRepoName } from "./deploy-engine-MHqWGHl9.js";
2
+ import { $ as coerceCfnBoolean, $t as resolveApp, A as cyan, An as LocalInvokeBuildError$1, At as createAssetRedirectResolver, B as findSilentDropProperties, Bn as StackHasActiveImportsError, Bt as validateContainerRepoName, C as makeCanonicalizePropertiesFn, Cn as resetAwsClients, Ct as S3StateBackend, D as renderStatefulReason, Dt as stringifyValue, E as isStatefulRecreateTargetSync, En as CdkdError, Et as AssetPublisher, F as IAMRoleProvider, Fn as NestedStackChildDirectDestroyError, Ft as BOOTSTRAP_MARKER_PREFIX, G as IntrinsicFunctionResolver, Gt as runDockerForeground, H as slowCcOperationTimeoutMs, Ht as buildDockerImage, I as collectInlinePolicyNamesManagedBySiblings, In as PartialFailureError, It as ensureAssetStorage, J as refStateLookupFromResource, Jn as isMarkedNonRetryable, Jt as getDockerImageBySourceHash, K as cfnRefValueFromPhysicalId, Kn as normalizeAwsError, Kt as runDockerStreaming, L as clearOnUpdateRemoval, Ln as ProvisioningError, Lt as getBootstrapMarkerKey, M as green, Mn as LocalStartServiceError, Mt as rewriteTemplateAssetReferences, N as red, Nt as escapeRegExp, O as formatResourceLine, Ot as WorkGraph, P as yellow, Pn as MissingCdkCliError, Pt as AssetModeResolver, Q as assertRegionMatch, Qt as getLegacyStateBucketName, R as ProviderRegistry, Rn as ResourceTimeoutError, Rt as parseBootstrapMarker, S as unsupportedFinalSnapshotError, Sn as getAwsClients, St as LockManager, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tt as shouldRetainResource, U as disableInstanceApiTermination, Ut as formatDockerLoginError, V as CloudControlProvider, Vn as StackTerminationProtectionError, Vt as buildDenyExternalAccessPolicy, W as isTerminationProtectionPropagationError, Wt as getDockerCmd, X as normalizeAwsTagsToCfn, Xn as isThrottlingError, Xt as synthesisStatusMessage, Y as WAFv2WebACLProvider, Yn as isRetryableTransientError, Yt as Synthesizer, Z as resolveExplicitPhysicalId, Zn as markNonRetryable, Zt as getDefaultStateBucketName, _ as buildFinalSnapshotIdentifier, _n as AssemblyReader, _t as INTRINSIC_KEYS, a as DeploymentEventsStore, an as resolveUseCdkBootstrapAssets, at as requireConfigObject, b as isFinalSnapshotError, bn as resolveBucketRegion, bt as DagBuilder, c as replayFailedOperations, cn as CFN_TEMPLATE_BODY_LIMIT, ct as redactSecretsForState, d as deleteSkipReason, dn as findLargeInlineResources, dt as s3BucketDomainName, en as resolveAutoAssetStorage, et as configBooleanRefusal, f as withResourceDeadline, fn as uploadCfnTemplate, ft as s3BucketDualStackDomainName, g as PRE_DELETE_SNAPSHOT_TYPES, gn as derivePartitionAndUrlSuffix, gt as DiffCalculator, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as canonicalizeRegion, ht as applyRoleArnIfSet, i as DeploymentEventsReader, in as resolveStateBucketWithDefaultAndSource, it as requireConfigArray, j as gray, jn as LocalMigrateError, jt as loadPublishableAssetManifest, k as bold, kn as DeployCancelledError, kt as buildAssetRedirectMap, l as replayRollback, ln as CFN_TEMPLATE_URL_LIMIT, lt as scrubResourceRecord, m as computeImplicitDeleteEdges, mn as PARTITION_TABLE, mt as s3BucketWebsiteUrl, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveSkipPrefix, nt as readConfigString, o as planFailedOps, on as stateBucketExistenceConfirmed, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as expectedOwnerParam, pt as s3BucketRegionalDomainName, q as getAccountInfo, qn as withErrorHandling, qt as AssetManifestLoader, r as DeployEngine, rn as resolveStateBucketWithDefault, rt as replayWarn, s as planRollback, sn as warnDeprecatedNoPrefixCliFlag, st as TEMPLATE_SOURCED_READBACK_RULES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveCaptureObservedState, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as MIGRATE_TMP_PREFIX, ut as s3BucketArn, v as ccRoutedFinalSnapshotError, vn as processStackMessages, vt as describeTypeWithThrottleRetry, w as extractDeploymentEventError, wn as setAwsClients, wt as rebuildClientForBucketRegion, x as refusesFinalSnapshot, xn as AwsClients, xt as TemplateParser, y as createPreDeleteFinalSnapshot, yt as withRetry, z as findActionableSilentDrops, zn as ResourceUpdateNotSupportedError, zt as validateAssetBucketName } from "./deploy-engine-CsvKYG0d.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-BqKgMaXN.js";
4
+ import { t as ASGProvider } from "./asg-provider-uNJDVURJ.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, 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";
@@ -21914,13 +21914,13 @@ const SG_INGRESS_IP_PROTOCOL_PATH = "AWS::EC2::SecurityGroupIngress IpProtocol";
21914
21914
  * hang a deploy; reaching it records nothing rather than answering from a
21915
21915
  * partial view.
21916
21916
  */
21917
- const MAX_SG_RULE_PAGES = 20;
21917
+ const MAX_SG_RULE_PAGES$1 = 20;
21918
21918
  /**
21919
21919
  * `MaxResults` for that walk. `DescribeSecurityGroupRules` accepts 5-1000; the
21920
21920
  * ceiling is what makes {@link MAX_SG_RULE_PAGES}'s "20,000 rules" true and
21921
21921
  * keeps the common case (a group with a handful of rules) to ONE round trip.
21922
21922
  */
21923
- const SG_RULE_PAGE_SIZE = 1e3;
21923
+ const SG_RULE_PAGE_SIZE$1 = 1e3;
21924
21924
  /**
21925
21925
  * The `sgr-…` security-group rule id, anchored.
21926
21926
  *
@@ -23339,8 +23339,8 @@ var EC2Provider = class {
23339
23339
  let nextToken;
23340
23340
  let pages = 0;
23341
23341
  do {
23342
- if (pages >= MAX_SG_RULE_PAGES) {
23343
- this.logger.debug(`SecurityGroupIngress ${logicalId}: DescribeSecurityGroupRules on ${groupId} still paginating after ${MAX_SG_RULE_PAGES} pages — cannot prove the match is unique, so not recording an Id attribute`);
23342
+ if (pages >= MAX_SG_RULE_PAGES$1) {
23343
+ this.logger.debug(`SecurityGroupIngress ${logicalId}: DescribeSecurityGroupRules on ${groupId} still paginating after ${MAX_SG_RULE_PAGES$1} pages — cannot prove the match is unique, so not recording an Id attribute`);
23344
23344
  return;
23345
23345
  }
23346
23346
  const resp = await this.ec2Client.send(new DescribeSecurityGroupRulesCommand({
@@ -23348,7 +23348,7 @@ var EC2Provider = class {
23348
23348
  Name: "group-id",
23349
23349
  Values: [groupId]
23350
23350
  }],
23351
- MaxResults: SG_RULE_PAGE_SIZE,
23351
+ MaxResults: SG_RULE_PAGE_SIZE$1,
23352
23352
  ...nextToken && { NextToken: nextToken }
23353
23353
  }));
23354
23354
  rules.push(...resp.SecurityGroupRules ?? []);
@@ -56744,6 +56744,307 @@ function createDeployCommand() {
56744
56744
  return cmd;
56745
56745
  }
56746
56746
 
56747
+ //#endregion
56748
+ //#region src/analyzer/outputs-diff.ts
56749
+ /**
56750
+ * A `${...}` placeholder `Fn::Sub` did NOT substitute.
56751
+ *
56752
+ * `resolveSub` catches a genuine `Ref` / `Fn::GetAtt` miss, WARNS, and keeps the
56753
+ * literal `${Foo}` in the output string — it neither throws nor leaves an
56754
+ * intrinsic object behind. So a half-substituted `Fn::Sub` reaches this module
56755
+ * looking like an ordinary resolved string, and comparing it against state
56756
+ * reports a phantom change whose printed value is `"arn:...${NewBucket}"`.
56757
+ *
56758
+ * Only consulted for a value whose TEMPLATE source actually contained an
56759
+ * `Fn::Sub` (see {@link templateUsesSub}). Applying it to every string
56760
+ * over-matches badly: an IAM policy body with `${aws:username}`, a UserData
56761
+ * snippet with a shell `${VAR}`, or any literal a user wrote would set
56762
+ * `resolutionFailed` and suppress the WHOLE Outputs section for that stack, on
56763
+ * every run, forever.
56764
+ *
56765
+ * Within `Fn::Sub`-sourced values one false positive remains and is accepted:
56766
+ * `${!Literal}` is `Fn::Sub`'s ESCAPE and resolves to the literal text
56767
+ * `${Literal}`, which post-resolution is indistinguishable from a placeholder
56768
+ * that failed to substitute. That case merely SUPPRESSES the section (the
56769
+ * pre-#1921 behavior) whereas the alternative is a phantom reported every run.
56770
+ */
56771
+ const UNSUBSTITUTED_SUB_PLACEHOLDER = /\$\{[^}]*\}/;
56772
+ /**
56773
+ * True when `value` is, or anywhere CONTAINS, something the diff could not
56774
+ * fully resolve against current state.
56775
+ *
56776
+ * The deploy engine's twin check is `Object.values(bag).some(v => v ===
56777
+ * undefined)` — its resolver THROWS on failure and the catch stores `undefined`.
56778
+ * The diff's resolver is best-effort and fails in three further ways that all
56779
+ * have to count as "unresolved" here, because anything that slips through is
56780
+ * compared against state and reported as a change the apply will never make:
56781
+ *
56782
+ * 1. `undefined` — the SAME signal the deploy side keys on. `resolve` returns it
56783
+ * WITHOUT throwing for a constructible-but-unknown attribute
56784
+ * (`AWS::DynamoDB::Table.StreamArn`, `AWS::IAM::Policy.PolicyId`,
56785
+ * `AWS::EC2::SecurityGroup.VpcId`, ...). `JSON.stringify` drops an
56786
+ * `undefined`-valued key, so state can never hold one and such an output
56787
+ * would otherwise report a PERMANENT phantom `ADD` printing `new: undefined`.
56788
+ * 2. A symbol — `Ref: AWS::NoValue` resolves to a sentinel symbol, which
56789
+ * `resolveValue` strips only INSIDE arrays / objects; a top-level
56790
+ * `Fn::If` selecting `AWS::NoValue` returns it bare. Not persistable, so
56791
+ * again a permanent phantom.
56792
+ * 3. A surviving intrinsic OBJECT, or — only when the template source used
56793
+ * `Fn::Sub` — an unsubstituted placeholder STRING (see
56794
+ * {@link UNSUBSTITUTED_SUB_PLACEHOLDER}).
56795
+ *
56796
+ * DEEP rather than shallow: an unresolved leaf can sit anywhere inside an array
56797
+ * or object an outer intrinsic already built.
56798
+ */
56799
+ function isUnresolvedValue(value, sourceUsedSub) {
56800
+ if (value === void 0) return true;
56801
+ if (typeof value === "symbol") return true;
56802
+ if (typeof value === "string") return sourceUsedSub && UNSUBSTITUTED_SUB_PLACEHOLDER.test(value);
56803
+ if (value === null || typeof value !== "object") return false;
56804
+ if (Array.isArray(value)) return value.some((v) => isUnresolvedValue(v, sourceUsedSub));
56805
+ const keys = Object.keys(value);
56806
+ if (keys.length === 1 && INTRINSIC_KEYS.has(keys[0])) return true;
56807
+ return Object.values(value).some((v) => isUnresolvedValue(v, sourceUsedSub));
56808
+ }
56809
+ /**
56810
+ * True when this output's RAW template value contains an `Fn::Sub` anywhere —
56811
+ * including nested inside an `Fn::Join` / `Fn::If`, since those resolve their
56812
+ * arguments and a laundered placeholder from an inner `Fn::Sub` surfaces in the
56813
+ * outer result.
56814
+ */
56815
+ function templateUsesSub(templateValue) {
56816
+ if (templateValue === null || typeof templateValue !== "object") return false;
56817
+ if (Array.isArray(templateValue)) return templateValue.some(templateUsesSub);
56818
+ const record = templateValue;
56819
+ if (Object.prototype.hasOwnProperty.call(record, "Fn::Sub")) return true;
56820
+ return Object.values(record).some(templateUsesSub);
56821
+ }
56822
+ /**
56823
+ * True when `value` is a string carrying a SECRET-BEARING CloudFormation
56824
+ * dynamic reference — `{{resolve:secretsmanager:` or `{{resolve:ssm-secure:`,
56825
+ * the two spellings that are secret regardless of what they point at.
56826
+ *
56827
+ * The diff resolves with `skipDynamicReferences`, so a secret-bearing output
56828
+ * arrives here as its unresolved `{{resolve:...}}` expression — which is also
56829
+ * what post-GHSA state stores. A state record written by an OLDER binary can
56830
+ * still hold the resolved PLAINTEXT instead (that mismatch is exactly what
56831
+ * `cdkd scrub` exists to repair), and printing it is this module's problem
56832
+ * because it is the first code path that DISPLAYS a stored output value.
56833
+ */
56834
+ function isSecretDynamicReference(value) {
56835
+ if (typeof value !== "string") return false;
56836
+ return value.includes("{{resolve:secretsmanager:") || value.includes("{{resolve:ssm-secure:");
56837
+ }
56838
+ /** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
56839
+ function valuesEqual(a, b) {
56840
+ if (a === b) return true;
56841
+ if (a == null || b == null) return a === b;
56842
+ if (typeof a !== typeof b) return false;
56843
+ if (typeof a !== "object") return false;
56844
+ if (Array.isArray(a) || Array.isArray(b)) {
56845
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
56846
+ return a.every((v, i) => valuesEqual(v, b[i]));
56847
+ }
56848
+ const ao = a;
56849
+ const bo = b;
56850
+ const ak = Object.keys(ao);
56851
+ if (ak.length !== Object.keys(bo).length) return false;
56852
+ for (const k of ak) {
56853
+ if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
56854
+ if (!valuesEqual(ao[k], bo[k])) return false;
56855
+ }
56856
+ return true;
56857
+ }
56858
+ /**
56859
+ * Resolve a template's `Outputs` section into the exact bag shape the deploy
56860
+ * engine persists to `StackState.outputs` (issue #1921).
56861
+ *
56862
+ * This is the preview twin of `DeployEngine.resolveOutputs`, and the three
56863
+ * semantics below are load-bearing for parity — a preview that builds a
56864
+ * DIFFERENT bag than the apply persists reports a phantom change on every run,
56865
+ * which is this issue's own bug class inverted. `tests/unit/analyzer/
56866
+ * outputs-diff.test.ts` fences each one against drift on the deploy side.
56867
+ *
56868
+ * 1. An output whose `Condition` evaluates false is SKIPPED, not resolved —
56869
+ * CloudFormation simply does not create it (mirrors the resource side's
56870
+ * `filterResourcesByCondition`; an unknown condition name is kept).
56871
+ * 2. An `Export.Name` is stored as a SECOND key holding the same value, so
56872
+ * `Fn::ImportValue` can find it by export name. Both keys land in state, so
56873
+ * both must land here.
56874
+ * 3. Resolution is best-effort and never throws — a diff must never harden into
56875
+ * an error where the deploy would have succeeded.
56876
+ *
56877
+ * The parity claim is scoped to the deploy engine's NO-CHANGE branch, which is
56878
+ * the one this preview stands in for. Its changed-resources branch resolves
56879
+ * outputs with no `resolutionFailed` gate at all — correctly, because by then
56880
+ * every resource exists and resolution is expected to succeed, whereas a diff
56881
+ * runs BEFORE any of them are created.
56882
+ *
56883
+ * Deliberately does NOT redact secrets the way the deploy side does: this
56884
+ * resolver runs with `skipDynamicReferences`, so a `{{resolve:...}}` reference
56885
+ * is never resolved to plaintext in the first place and the bag already holds
56886
+ * the expression that state stores post-redaction.
56887
+ */
56888
+ async function resolveTemplateOutputs(template, resolveFn, conditions) {
56889
+ const logger = getLogger().child("OutputsDiff");
56890
+ const outputs = {};
56891
+ const exportNames = /* @__PURE__ */ new Set();
56892
+ const failedKeys = /* @__PURE__ */ new Set();
56893
+ const secretSourceKeys = /* @__PURE__ */ new Set();
56894
+ let resolutionFailed = false;
56895
+ if (!template.Outputs) return {
56896
+ outputs,
56897
+ exportNames,
56898
+ failedKeys,
56899
+ secretSourceKeys,
56900
+ resolutionFailed
56901
+ };
56902
+ /**
56903
+ * Record a key the resolver DROPPED — plus the literal `Export.Name` alias the
56904
+ * deploy would have written alongside it. Both are absent from the bag, so
56905
+ * both would otherwise read as phantom REMOVEs and make the suppression
56906
+ * warning fire on the ordinary pending-resource case.
56907
+ */
56908
+ const recordFailure = (outputKey, output) => {
56909
+ failedKeys.add(outputKey);
56910
+ if (typeof output.Export?.Name === "string") failedKeys.add(output.Export.Name);
56911
+ };
56912
+ for (const [outputKey, output] of Object.entries(template.Outputs)) {
56913
+ if (!isSecretDynamicReference(output.Value)) continue;
56914
+ secretSourceKeys.add(outputKey);
56915
+ if (typeof output.Export?.Name === "string") secretSourceKeys.add(output.Export.Name);
56916
+ }
56917
+ for (const [outputKey, output] of Object.entries(template.Outputs)) {
56918
+ if (output.Condition !== void 0 && conditions?.[output.Condition] === false) {
56919
+ logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
56920
+ continue;
56921
+ }
56922
+ const sourceUsedSub = templateUsesSub(output.Value);
56923
+ let value;
56924
+ try {
56925
+ value = await resolveFn(structuredClone(output.Value));
56926
+ } catch (error) {
56927
+ logger.debug(`Diff could not resolve output ${outputKey}: ${String(error)}`);
56928
+ resolutionFailed = true;
56929
+ recordFailure(outputKey, output);
56930
+ continue;
56931
+ }
56932
+ if (isUnresolvedValue(value, sourceUsedSub)) {
56933
+ logger.debug(`Diff left output ${outputKey} unresolved (references a pending resource)`);
56934
+ resolutionFailed = true;
56935
+ recordFailure(outputKey, output);
56936
+ continue;
56937
+ }
56938
+ outputs[outputKey] = value;
56939
+ if (output.Export?.Name) {
56940
+ let exportName = output.Export.Name;
56941
+ const exportSourceUsedSub = templateUsesSub(output.Export.Name);
56942
+ if (typeof exportName !== "string") try {
56943
+ exportName = await resolveFn(structuredClone(exportName));
56944
+ } catch (error) {
56945
+ logger.debug(`Diff could not resolve Export.Name of ${outputKey}: ${String(error)}`);
56946
+ resolutionFailed = true;
56947
+ continue;
56948
+ }
56949
+ if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) {
56950
+ outputs[exportName] = value;
56951
+ exportNames.add(exportName);
56952
+ } else resolutionFailed = true;
56953
+ }
56954
+ }
56955
+ return {
56956
+ outputs,
56957
+ exportNames,
56958
+ failedKeys,
56959
+ secretSourceKeys,
56960
+ resolutionFailed
56961
+ };
56962
+ }
56963
+ /**
56964
+ * Compare the persisted Outputs bag against the one the next deploy would
56965
+ * write, key by key (issue #1921).
56966
+ *
56967
+ * Compares BAG KEYS rather than template output names on purpose. The bag is
56968
+ * what actually lands in `StackState.outputs` AND what
56969
+ * `ExportIndexStore.updateForStack` publishes, so a key-level comparison is
56970
+ * exactly the `outputMapsEqual` predicate the deploy engine gates its persist
56971
+ * on — no interpretive layer in between that could drift from apply semantics.
56972
+ * The practical payoff is that the motivating #875 case shows the user the
56973
+ * literal export name their downstream `Fn::ImportValue` needs.
56974
+ *
56975
+ * ## Withholding legacy secret plaintext
56976
+ *
56977
+ * This is the first code path that DISPLAYS a stored output value, and
56978
+ * `cdkd diff` is routinely run in CI — so a value that turns out to be secret
56979
+ * plaintext would land in build logs. `StackState.outputs` is only guaranteed
56980
+ * redacted for records written after the GHSA fix; an older binary persisted
56981
+ * the RESOLVED plaintext, which is the condition `cdkd scrub` repairs.
56982
+ *
56983
+ * Two independent signals identify such a record, and BOTH are needed:
56984
+ *
56985
+ * - the desired side is still a secret-bearing expression per
56986
+ * {@link isSecretDynamicReference} (this resolver runs with
56987
+ * `skipDynamicReferences`) while the stored side is not; and
56988
+ * - `secretSourceKeys` — the template itself declares the key's value as such a
56989
+ * reference. This one reaches cases the first cannot: a
56990
+ * condition-skipped secret output, or one deleted from the template, has NO
56991
+ * desired side at all and would otherwise print in full as a REMOVE row.
56992
+ *
56993
+ * A hit on either makes the WHOLE record suspect, not just that key: the
56994
+ * record was written by a pre-GHSA binary, so every value in it is unredacted.
56995
+ * Withholding is therefore record-level. The change is still REPORTED — only
56996
+ * the value is withheld — so `--fail` and the exports story are unaffected.
56997
+ */
56998
+ function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set()) {
56999
+ const changes = [];
57000
+ const currentBag = current ?? {};
57001
+ const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
57002
+ if (isSecretDynamicReference(oldValue)) return false;
57003
+ return isSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
57004
+ });
57005
+ const push = (change) => {
57006
+ if (legacyRecord && change.changeType !== "ADD") {
57007
+ const { oldValue: _dropped, ...rest } = change;
57008
+ changes.push({
57009
+ ...rest,
57010
+ oldValueRedacted: true
57011
+ });
57012
+ return;
57013
+ }
57014
+ changes.push(change);
57015
+ };
57016
+ for (const [name, newValue] of Object.entries(desired)) {
57017
+ const isExport = exportNames.has(name);
57018
+ if (!Object.prototype.hasOwnProperty.call(currentBag, name)) {
57019
+ changes.push({
57020
+ name,
57021
+ changeType: "ADD",
57022
+ newValue,
57023
+ isExport
57024
+ });
57025
+ continue;
57026
+ }
57027
+ const oldValue = currentBag[name];
57028
+ if (!valuesEqual(oldValue, newValue)) push({
57029
+ name,
57030
+ changeType: "MODIFY",
57031
+ oldValue,
57032
+ newValue,
57033
+ isExport
57034
+ });
57035
+ }
57036
+ for (const [name, oldValue] of Object.entries(currentBag)) {
57037
+ if (Object.prototype.hasOwnProperty.call(desired, name)) continue;
57038
+ push({
57039
+ name,
57040
+ changeType: "REMOVE",
57041
+ oldValue,
57042
+ isExport: false
57043
+ });
57044
+ }
57045
+ return changes;
57046
+ }
57047
+
56747
57048
  //#endregion
56748
57049
  //#region src/cli/cfn-stack-states.ts
56749
57050
  /**
@@ -57705,6 +58006,11 @@ async function loadStateOrEmpty(stackName, region, stateBackend) {
57705
58006
  * resolver so changes buried inside intrinsics (e.g. `Fn::Join` literal
57706
58007
  * args) are detected against resolved values in state.
57707
58008
  *
58009
+ * Also computes the `Outputs` delta (issue #1921) — an Outputs-only change has
58010
+ * a byte-identical `Resources` section, so without it such a stack previews as
58011
+ * "No changes detected" while the apply persists new outputs and republishes
58012
+ * the exports index.
58013
+ *
57708
58014
  * Pure with respect to AWS state mutation — only reads state (the resolver
57709
58015
  * may read producer state for `Fn::ImportValue` / `Fn::GetStackOutput`).
57710
58016
  */
@@ -57749,7 +58055,16 @@ async function computeStackDiff(currentState, template, region, stackName, state
57749
58055
  ...conditions && { conditions },
57750
58056
  skipDynamicReferences: true
57751
58057
  });
57752
- return diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
58058
+ const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
58059
+ const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions);
58060
+ let outputChanges = [];
58061
+ if (resolved.resolutionFailed) {
58062
+ if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
58063
+ } else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys);
58064
+ return {
58065
+ changes,
58066
+ outputChanges
58067
+ };
57753
58068
  }
57754
58069
  /**
57755
58070
  * Resolve a nested-stack child's input `Parameters` (declared on the parent's
@@ -57812,12 +58127,14 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
57812
58127
  async function buildDiffTree(args) {
57813
58128
  const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
57814
58129
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
58130
+ const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback);
57815
58131
  const node = {
57816
58132
  stackName,
57817
58133
  displayName,
57818
58134
  region,
57819
- changes: await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback),
58135
+ changes,
57820
58136
  ccApiRoutes: collectCcApiRoutes(template, state),
58137
+ outputChanges,
57821
58138
  children: []
57822
58139
  };
57823
58140
  if (!recursive) return node;
@@ -57862,12 +58179,14 @@ async function buildDiffTree(args) {
57862
58179
  */
57863
58180
  async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculator) {
57864
58181
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
58182
+ const { changes, outputChanges } = await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator);
57865
58183
  const node = {
57866
58184
  stackName,
57867
58185
  displayName: stackName,
57868
58186
  region,
57869
- changes: await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator),
58187
+ changes,
57870
58188
  ccApiRoutes: /* @__PURE__ */ new Map(),
58189
+ outputChanges,
57871
58190
  children: []
57872
58191
  };
57873
58192
  for (const [logicalId, resource] of Object.entries(state.resources)) {
@@ -57925,7 +58244,7 @@ function collectCcApiRoutes(template, state) {
57925
58244
  /** True when this node has at least one real (non-`NO_CHANGE`) change. */
57926
58245
  function nodeHasChanges(node) {
57927
58246
  for (const change of node.changes.values()) if (change.changeType !== "NO_CHANGE") return true;
57928
- return false;
58247
+ return node.outputChanges.length > 0;
57929
58248
  }
57930
58249
  /** True when this node OR any descendant has a real change (tree-wide drift detector for `--fail`). */
57931
58250
  function treeHasChanges(node) {
@@ -57956,26 +58275,17 @@ function diffTreeToJson(node) {
57956
58275
  stack: node.stackName,
57957
58276
  region: node.region,
57958
58277
  changes,
58278
+ outputChanges: node.outputChanges.map((change) => ({
58279
+ name: change.name,
58280
+ changeType: change.changeType,
58281
+ ...change.changeType !== "ADD" && !change.oldValueRedacted ? { oldValue: change.oldValue } : {},
58282
+ ...change.oldValueRedacted ? { oldValueRedacted: true } : {},
58283
+ ...change.changeType !== "REMOVE" ? { newValue: change.newValue } : {},
58284
+ export: change.isExport
58285
+ })),
57959
58286
  children: node.children.map(diffTreeToJson)
57960
58287
  };
57961
58288
  }
57962
- const INTRINSIC_KEYS = /* @__PURE__ */ new Set([
57963
- "Ref",
57964
- "Fn::Sub",
57965
- "Fn::GetAtt",
57966
- "Fn::Join",
57967
- "Fn::Select",
57968
- "Fn::Split",
57969
- "Fn::If",
57970
- "Fn::ImportValue",
57971
- "Fn::FindInMap",
57972
- "Fn::Base64",
57973
- "Fn::GetAZs",
57974
- "Fn::Equals",
57975
- "Fn::And",
57976
- "Fn::Or",
57977
- "Fn::Not"
57978
- ]);
57979
58289
  function isIntrinsic(value) {
57980
58290
  if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return false;
57981
58291
  const keys = Object.keys(value);
@@ -58111,6 +58421,84 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
58111
58421
  delete: deleteCount
58112
58422
  };
58113
58423
  }
58424
+ /** Stand-in printed instead of a withheld legacy-plaintext output value. */
58425
+ const REDACTED_LEGACY_PLAINTEXT = "<redacted: legacy plaintext in state — run `cdkd scrub`>";
58426
+ /**
58427
+ * Strip characters that let a template-controlled string manipulate the
58428
+ * TERMINAL rather than merely appear in it: C0 (incl. ESC, so no ANSI sequence
58429
+ * survives), DEL, C1 (the 8-bit CSI forms some terminals still honor), and the
58430
+ * bidi / format overrides that visually reorder a rendered line.
58431
+ *
58432
+ * Applied only on the human-render path. The `--json` payload is deliberately
58433
+ * left byte-faithful: it is a machine interface, an export NAME is data a
58434
+ * consumer may match on, and silently mutating it there would trade a real
58435
+ * correctness regression for a display concern belonging to whatever renders
58436
+ * the JSON. `JSON.stringify` already escapes everything below 0x20.
58437
+ */
58438
+ function stripControlChars(value) {
58439
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
58440
+ }
58441
+ /**
58442
+ * The same guard for an already-JSON-SERIALIZED value: C1 and the bidi marks
58443
+ * only, deliberately NOT C0.
58444
+ *
58445
+ * `JSON.stringify` escapes every character below 0x20 that occurs INSIDE a
58446
+ * string, so the only C0 left on this path is the pretty-printer's own
58447
+ * structural newlines — stripping those collapses a multi-line value onto a
58448
+ * single line. What `JSON.stringify` passes through unchanged is the C1 range
58449
+ * and the bidi overrides, and those are what this removes.
58450
+ */
58451
+ function stripDisplayOnlyChars(value) {
58452
+ return value.replace(/[\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
58453
+ }
58454
+ /**
58455
+ * Render one node's Outputs delta (issue #1921) via `logFn`, returning the
58456
+ * per-kind counts. Emits nothing when there is no delta, so an unchanged
58457
+ * Outputs section adds no noise to a resource-only diff.
58458
+ *
58459
+ * Rows are keyed by the PERSISTED bag key, which is what `StackState.outputs`
58460
+ * holds and what the exports index publishes. An output carrying an
58461
+ * `Export.Name` therefore shows TWO rows — its logical name and its export
58462
+ * name — because the deploy writes both keys. That is the useful half for the
58463
+ * motivating case: the `[export]`-tagged row IS the string a downstream
58464
+ * `Fn::ImportValue` resolves, so the user can match it against the consumer
58465
+ * that was failing with "export not found".
58466
+ */
58467
+ function renderOutputChangeLines(outputChanges, logFn) {
58468
+ const counts = {
58469
+ add: 0,
58470
+ change: 0,
58471
+ remove: 0
58472
+ };
58473
+ if (outputChanges.length === 0) return counts;
58474
+ logFn("\n Outputs:");
58475
+ const indent = " ";
58476
+ const render = (value) => stripDisplayOnlyChars((JSON.stringify(value, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`));
58477
+ const renderOld = (change) => change.oldValueRedacted ? REDACTED_LEGACY_PLAINTEXT : render(change.oldValue);
58478
+ for (const change of outputChanges) {
58479
+ const exported = change.isExport ? " [export]" : "";
58480
+ const name = stripControlChars(change.name);
58481
+ switch (change.changeType) {
58482
+ case "ADD":
58483
+ counts.add++;
58484
+ logFn(` [+] ${name}${exported}`);
58485
+ logFn(` new: ${render(change.newValue)}`);
58486
+ break;
58487
+ case "MODIFY":
58488
+ counts.change++;
58489
+ logFn(` [~] ${name}${exported}`);
58490
+ logFn(` old: ${renderOld(change)}`);
58491
+ logFn(` new: ${render(change.newValue)}`);
58492
+ break;
58493
+ case "REMOVE":
58494
+ counts.remove++;
58495
+ logFn(` [-] ${name}${exported}`);
58496
+ logFn(` old: ${renderOld(change)}`);
58497
+ break;
58498
+ }
58499
+ }
58500
+ return counts;
58501
+ }
58114
58502
  /**
58115
58503
  * Render a diff tree (root + nested children, DFS) via `logFn`. Only nodes
58116
58504
  * that actually have changes get a block — unchanged nested children are
@@ -58123,7 +58511,9 @@ function renderDiffTree(node, isRoot, logFn) {
58123
58511
  if (nodeHasChanges(node)) {
58124
58512
  logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
58125
58513
  const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes);
58514
+ const outputs = renderOutputChangeLines(node.outputChanges, logFn);
58126
58515
  logFn(`\n${create} to create, ${update} to update, ${del} to delete`);
58516
+ if (outputs.add + outputs.change + outputs.remove > 0) logFn(`${outputs.add} output(s) to add, ${outputs.change} to change, ${outputs.remove} to remove`);
58127
58517
  }
58128
58518
  for (const child of node.children) renderDiffTree(child, false, logFn);
58129
58519
  }
@@ -61855,6 +62245,18 @@ function readStringProperty(properties, key, resourceType) {
61855
62245
  return v;
61856
62246
  }
61857
62247
  /**
62248
+ * Build the live-read seam for ONE {@link buildImportPlan} call, with a fresh
62249
+ * memo. A factory rather than an inline object literal so the cache cannot be
62250
+ * forgotten at a future call site (the field is required, so omitting it is a
62251
+ * type error, and sharing one across calls has to be written deliberately).
62252
+ */
62253
+ function createIdentifierBackfillDeps(ec2Client) {
62254
+ return {
62255
+ ec2Client,
62256
+ securityGroupRuleCache: /* @__PURE__ */ new Map()
62257
+ };
62258
+ }
62259
+ /**
61858
62260
  * Types whose cdkd physicalId is a COMPOSITE (`|`-joined) string while
61859
62261
  * CloudFormation's `primaryIdentifier` is a SINGLE field — so the physicalId is
61860
62262
  * NOT the identifier value, and it is not a SPLIT of the composite either
@@ -61987,6 +62389,7 @@ function recordedRuleIdIdentifier() {
61987
62389
  const isUsableIdentifier = (value) => SG_RULE_ID_PATTERN.test(value.trim());
61988
62390
  return {
61989
62391
  field: "Id",
62392
+ backfill: backfillSecurityGroupIngressRuleId,
61990
62393
  resolve: ({ logicalId, physicalId, attributes }) => {
61991
62394
  const recorded = attributes["Id"];
61992
62395
  if (typeof recorded === "string" && isUsableIdentifier(recorded)) return recorded.trim();
@@ -61997,6 +62400,292 @@ function recordedRuleIdIdentifier() {
61997
62400
  };
61998
62401
  }
61999
62402
  /**
62403
+ * Page ceiling for the {@link backfillSecurityGroupIngressRuleId} walk, and its
62404
+ * `MaxResults`. The twins of `MAX_SG_RULE_PAGES` / `SG_RULE_PAGE_SIZE` in
62405
+ * `src/provisioning/providers/ec2-provider.ts`, restated here for the same
62406
+ * reason {@link SG_RULE_ID_PATTERN} is: neither module may pull the other's
62407
+ * graph.
62408
+ *
62409
+ * 20 pages x 1000 rules covers 20,000 rules on ONE security group, far beyond
62410
+ * any real one (AWS's default quota is 60 rules per SG, 1000 at the maximum
62411
+ * increase). That arithmetic only holds because the request pins `MaxResults` —
62412
+ * without it AWS applies its own page size and the bound would cap an unknown
62413
+ * number of rules. Exhausting it is treated as "could not see the whole group",
62414
+ * which is a REFUSAL here rather than the provider's silent `undefined`: the
62415
+ * uniqueness verdict below is a statement about the whole group, so answering
62416
+ * from a partial view is exactly how a genuine 2-match becomes a false
62417
+ * "exactly one".
62418
+ */
62419
+ const MAX_SG_RULE_PAGES = 20;
62420
+ const SG_RULE_PAGE_SIZE = 1e3;
62421
+ /**
62422
+ * Parse cdkd's `<groupId>|<ipProtocol>|<fromPort>|<toPort>` physical id, or
62423
+ * `undefined` when it is not that shape.
62424
+ *
62425
+ * EXACTLY four segments, all non-blank. A longer id is the issue
62426
+ * [#1672](https://github.com/go-to-k/cdkd/issues/1672) ambiguity — a segment
62427
+ * carrying the separator — and decoding its first four parts would silently
62428
+ * look up a DIFFERENT rule's tuple, so it is refused like any other unparseable
62429
+ * shape.
62430
+ *
62431
+ * The ports are the `'-1'` AWS itself uses for "all ports", which is what
62432
+ * `EC2Provider` packs when the template declares no `FromPort` / `ToPort`; a
62433
+ * segment that is not a finite number yields `undefined` rather than `NaN`, so
62434
+ * a malformed id refuses instead of matching nothing and blaming AWS.
62435
+ *
62436
+ * A BLANK port segment is refused EXPLICITLY, before `Number()` ever sees it,
62437
+ * because `Number('')` is `0` rather than `NaN` — so `sg-x|tcp||443` would
62438
+ * otherwise parse to `fromPort: 0` and go and look up a REAL tuple (ICMP type
62439
+ * 0 is echo reply, a live rule shape), adopting an id for a rule the record
62440
+ * does not name. That is also why the blank is not simply folded to `-1`: this
62441
+ * module and `EC2Provider.cfnIngressPortValue` disagree about what a blank
62442
+ * MEANS — the provider reads it as `-1` (all ports) because its input is a CFn
62443
+ * property the template legitimately omits, while here it is a SEGMENT of an
62444
+ * id cdkd itself wrote, and cdkd never writes an empty one. A blank segment is
62445
+ * therefore evidence the id is damaged, not evidence of an absent port.
62446
+ */
62447
+ function parseSecurityGroupIngressComposite(physicalId) {
62448
+ const parts = physicalId.trim().split("|");
62449
+ if (parts.length !== 4) return void 0;
62450
+ const [groupId, ipProtocol, fromPort, toPort] = parts;
62451
+ if (!groupId.trim() || !ipProtocol.trim()) return void 0;
62452
+ const fromRaw = fromPort.trim();
62453
+ const toRaw = toPort.trim();
62454
+ if (!fromRaw || !toRaw) return void 0;
62455
+ const from = Number(fromRaw);
62456
+ const to = Number(toRaw);
62457
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return void 0;
62458
+ return {
62459
+ groupId: groupId.trim(),
62460
+ ipProtocol: ipProtocol.trim(),
62461
+ fromPort: from,
62462
+ toPort: to
62463
+ };
62464
+ }
62465
+ /**
62466
+ * Does this `DescribeSecurityGroupRules` row describe the rule cdkd's composite
62467
+ * physical id names?
62468
+ *
62469
+ * The protocol goes through {@link canonicalizeIpProtocolValue} on BOTH sides
62470
+ * rather than a raw compare: EC2 rewrites the four protocol numbers it has a
62471
+ * name for before storing the rule, so a record packed from `IpProtocol: 6`
62472
+ * carries `'6'` while the readback says `'tcp'` and a raw compare would match
62473
+ * nothing (the issue #1643 fold, at a fourth call site).
62474
+ *
62475
+ * An omitted port reads back as `-1`, which is the same spelling the composite
62476
+ * carries for an absent one — so the two sides are directly comparable.
62477
+ */
62478
+ function ruleMatchesIngressTuple(rule, tuple) {
62479
+ if (rule.IsEgress === true) return false;
62480
+ if (canonicalizeIpProtocolValue(rule.IpProtocol) !== canonicalizeIpProtocolValue(tuple.ipProtocol)) return false;
62481
+ if ((rule.FromPort ?? -1) !== tuple.fromPort) return false;
62482
+ if ((rule.ToPort ?? -1) !== tuple.toPort) return false;
62483
+ return true;
62484
+ }
62485
+ /**
62486
+ * Test seam: overriding `sleep` lets unit tests drive the throttle backoff
62487
+ * without real waits. Mirrors `describeTypeRetryDelays` in
62488
+ * `src/provisioning/describe-type.ts`, which this retry is modeled on.
62489
+ */
62490
+ const securityGroupRuleLookupRetryDelays = {};
62491
+ /**
62492
+ * Retries after the first attempt, THROTTLE-shaped failures only. At the
62493
+ * default backoff (1s -> 2s -> 4s -> 8s) this adds at most ~15s of sleep,
62494
+ * which is small next to what it prevents: `cdkd export` is all-or-nothing, so
62495
+ * one `RequestLimitExceeded` — the commonest EC2 Describe failure there is —
62496
+ * would otherwise abort the WHOLE run. A non-throttle failure (a missing
62497
+ * `ec2:DescribeSecurityGroupRules` being the important one) is surfaced
62498
+ * immediately, since it cannot heal by waiting.
62499
+ */
62500
+ const MAX_SG_RULE_THROTTLE_RETRIES = 4;
62501
+ /**
62502
+ * Does this failure say the CALLER lacks permission, as opposed to anything
62503
+ * else AWS can fail with?
62504
+ *
62505
+ * Only these get the "Grant ec2:DescribeSecurityGroupRules" sentence. Sending
62506
+ * every failure to that remedy is how a throttle got reported as a permissions
62507
+ * problem: the user checks a policy that was correct all along, while the
62508
+ * actual advice ("re-run it") goes unsaid.
62509
+ */
62510
+ function isAuthorizationShapedError(err) {
62511
+ const name = err instanceof Error ? err.name : "";
62512
+ const message = err instanceof Error ? err.message : String(err);
62513
+ return /AccessDenied|UnauthorizedOperation|AuthFailure|not authorized to perform/i.test(`${name} ${message}`);
62514
+ }
62515
+ /**
62516
+ * Walk EVERY ingress/egress rule AWS holds on one security group, retrying a
62517
+ * throttled page (issue [#1791](https://github.com/go-to-k/cdkd/issues/1791)
62518
+ * review).
62519
+ *
62520
+ * Returns the failure rather than throwing it, because the caller owns the
62521
+ * message: every refusal here names the cdkd ROW that could not be resolved,
62522
+ * and this function does not know which row (possibly rows, via the memo)
62523
+ * asked.
62524
+ */
62525
+ async function describeAllSecurityGroupRules(groupId, ec2Client) {
62526
+ const rules = [];
62527
+ let nextToken;
62528
+ let pages = 0;
62529
+ do {
62530
+ if (pages >= MAX_SG_RULE_PAGES) return {
62531
+ ok: false,
62532
+ kind: "truncated"
62533
+ };
62534
+ let resp;
62535
+ try {
62536
+ resp = await withRetry(() => ec2Client.send(new DescribeSecurityGroupRulesCommand({
62537
+ Filters: [{
62538
+ Name: "group-id",
62539
+ Values: [groupId]
62540
+ }],
62541
+ MaxResults: SG_RULE_PAGE_SIZE,
62542
+ ...nextToken && { NextToken: nextToken }
62543
+ })), `DescribeSecurityGroupRules(${groupId})`, {
62544
+ maxRetries: MAX_SG_RULE_THROTTLE_RETRIES,
62545
+ isRetryable: (_message, error) => isThrottlingError(error),
62546
+ logger: getLogger().child("Export"),
62547
+ ...securityGroupRuleLookupRetryDelays.sleep ? { sleep: securityGroupRuleLookupRetryDelays.sleep } : {}
62548
+ });
62549
+ } catch (err) {
62550
+ const detail = err instanceof Error ? err.message : String(err);
62551
+ return {
62552
+ ok: false,
62553
+ kind: isAuthorizationShapedError(err) ? "auth" : isThrottlingError(err) ? "throttled" : "failed",
62554
+ detail
62555
+ };
62556
+ }
62557
+ rules.push(...resp.SecurityGroupRules ?? []);
62558
+ nextToken = resp.NextToken;
62559
+ pages += 1;
62560
+ } while (nextToken);
62561
+ return {
62562
+ ok: true,
62563
+ rules
62564
+ };
62565
+ }
62566
+ /**
62567
+ * {@link describeAllSecurityGroupRules}, memoized per GROUP for the lifetime of
62568
+ * one {@link buildImportPlan} call — see
62569
+ * {@link IdentifierBackfillDeps.securityGroupRuleCache}.
62570
+ *
62571
+ * The PROMISE is cached, not its resolution, so rows resolved concurrently on
62572
+ * one group also share a single walk rather than racing to start their own.
62573
+ */
62574
+ function cachedSecurityGroupRules(groupId, { ec2Client, securityGroupRuleCache }) {
62575
+ const cached = securityGroupRuleCache.get(groupId);
62576
+ if (cached) return cached;
62577
+ const pending = describeAllSecurityGroupRules(groupId, ec2Client);
62578
+ securityGroupRuleCache.set(groupId, pending);
62579
+ return pending;
62580
+ }
62581
+ /**
62582
+ * What to DO about a row more than one AWS rule matches — the part of the
62583
+ * refusal that is about the ambiguity itself rather than about how cdkd
62584
+ * noticed it.
62585
+ *
62586
+ * Written once because TWO arms below report a matched count above one: the
62587
+ * plain `> 1` refusal, and the unusable-id refusal (whose own remedy is "open
62588
+ * a cdkd issue" — right about the unreadable id, useless about the ambiguity).
62589
+ * A row that hits the second arm has BOTH problems, and before this was shared
62590
+ * it was the one row whose user never saw these two causes at all.
62591
+ *
62592
+ * Carries no row identity, so it can be appended to either message.
62593
+ */
62594
+ const SG_INGRESS_AMBIGUITY_REMEDY = "Two causes, with different remedies. (1) ONE resource declaring MORE THAN ONE source (e.g. both CidrIp and CidrIpv6 on the same resource), for which AWS mints one rule per source: split it into one AWS::EC2::SecurityGroupIngress resource per source, which is also the shape CloudFormation will manage after the export. (2) TWO DISTINCT AWS::EC2::SecurityGroupIngress resources that differ only by SOURCE — port 443 from a CIDR and port 443 from a peer security group, say. cdkd's composite physical id carries no source, so those two rows carry byte-identical ids and splitting is not their remedy: set the row's attributes.Id to the 'sgr-...' id that belongs to it (cdkd then resolves from state and issues no lookup at all), or remove the row from the stack before exporting. Then re-run cdkd export.";
62595
+ /** The remedy every refusal below ends with — one place, so they cannot drift. */
62596
+ function sgIngressExportEscapeHatch(logicalId) {
62597
+ return `You can instead remove '${logicalId}' from the stack before exporting — the rule stays in AWS and can be re-declared in CloudFormation afterwards.`;
62598
+ }
62599
+ /**
62600
+ * Live-read backfill of the `sgr-...` rule id for an
62601
+ * `AWS::EC2::SecurityGroupIngress` row that state does not carry one for
62602
+ * (issue [#1791](https://github.com/go-to-k/cdkd/issues/1791)).
62603
+ *
62604
+ * ## Why a live read at all
62605
+ *
62606
+ * Every row written by a cdkd older than issue #1761 has `attributes: {}`, and
62607
+ * `cdkd export` is ALL-OR-NOTHING — so ONE such row makes a whole stack
62608
+ * un-exportable. The obvious workaround does not exist: AWS returns the id only
62609
+ * from `AuthorizeSecurityGroupIngress`'s own response, so a no-op `cdkd deploy`
62610
+ * records nothing and the row is exactly as unexportable afterwards. Only a
62611
+ * re-authorization (a property change, or a destroy + deploy) heals it, which
62612
+ * is a traffic interruption to pay for a read cdkd can perform itself.
62613
+ *
62614
+ * ## Why the match is on the composite's tuple, and why EXACTLY ONE
62615
+ *
62616
+ * The row's identity, as far as cdkd state is concerned, IS
62617
+ * `<groupId>|<ipProtocol>|<fromPort>|<toPort>` — the tuple its own revoke call
62618
+ * needs. So the tuple is the most cdkd can filter on, and two AWS rules sharing
62619
+ * it are two rules cdkd's own physical id cannot tell apart either. Adopting
62620
+ * one of them would record an identifier that names the wrong AWS object, which
62621
+ * is the failure mode the #1761 already-exists arm's "exactly one" rule exists
62622
+ * to prevent — so ambiguity REFUSES, naming the candidates.
62623
+ *
62624
+ * The commonest ambiguity is the multi-source rule the state-only message
62625
+ * already describes: a resource declaring both `CidrIp` and `CidrIpv6` makes
62626
+ * AWS mint one rule PER SOURCE, both with this exact tuple. Splitting the
62627
+ * resource is the remedy in that case, and it is the same remedy CloudFormation
62628
+ * will want after the export. It is NOT the only cause, which is why the
62629
+ * refusal names two: TWO DISTINCT `AWS::EC2::SecurityGroupIngress` resources
62630
+ * differing only by source share one tuple as well — cdkd's composite carries
62631
+ * no source — and those are already one resource per source, so the remedy
62632
+ * there is to repair the row's `attributes.Id` (or drop the row) instead.
62633
+ *
62634
+ * Deliberately NOT narrowed by the recorded SOURCE (`CidrIp` and friends) even
62635
+ * though state carries it: {@link CompositePhysicalIdContext} does not carry
62636
+ * `properties`, and widening it to make ONE ambiguous case resolvable would
62637
+ * also make the export adopt a rule the physical id in state does not uniquely
62638
+ * name — a row that looks adopted while `cdkd destroy` would revoke a different
62639
+ * one. Refusing is the answer that keeps state and AWS agreeing.
62640
+ */
62641
+ async function backfillSecurityGroupIngressRuleId({ logicalId, physicalId }, deps) {
62642
+ const tuple = parseSecurityGroupIngressComposite(physicalId);
62643
+ if (!tuple) throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}', and its physical id '${physicalId}' is not cdkd's '<groupId>|<ipProtocol>|<fromPort>|<toPort>' composite either, so cdkd cannot look the rule up in AWS to recover the id CloudFormation IMPORT needs. Repair the record's physicalId (or its attributes.Id, which may be the 'sgr-...' id verbatim) and re-run cdkd export. ${sgIngressExportEscapeHatch(logicalId)}`);
62644
+ const portRange = tuple.fromPort === tuple.toPort ? `${tuple.fromPort}` : `${tuple.fromPort}-${tuple.toPort}`;
62645
+ const tupleNote = `protocol '${tuple.ipProtocol}', ports ${portRange} on security group '${tuple.groupId}'`;
62646
+ const lookup = await cachedSecurityGroupRules(tuple.groupId, deps);
62647
+ if (!lookup.ok) {
62648
+ if (lookup.kind === "truncated") throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}', and the DescribeSecurityGroupRules lookup on '${tuple.groupId}' was still paginating after ${MAX_SG_RULE_PAGES} pages — cdkd cannot prove a match is unique without seeing the whole group, and adopting an unproven id would name the wrong rule. ${sgIngressExportEscapeHatch(logicalId)}`);
62649
+ const remedy = lookup.kind === "auth" ? `Grant ec2:DescribeSecurityGroupRules and re-run cdkd export.` : lookup.kind === "throttled" ? `AWS throttled the lookup and cdkd retried it ${MAX_SG_RULE_THROTTLE_RETRIES} times before giving up — no permission is missing. Re-run cdkd export once the account's EC2 describe rate has recovered.` : `Re-run cdkd export once that cause is resolved.`;
62650
+ throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}' (the rule was deployed by a cdkd older than https://github.com/go-to-k/cdkd/issues/1761), and the DescribeSecurityGroupRules lookup that would have recovered it failed: ${lookup.detail}. ${remedy} ${sgIngressExportEscapeHatch(logicalId)}`);
62651
+ }
62652
+ const matched = lookup.rules.filter((rule) => ruleMatchesIngressTuple(rule, tuple));
62653
+ const ids = matched.map((rule) => rule.SecurityGroupRuleId).filter((id) => typeof id === "string" && SG_RULE_ID_PATTERN.test(id.trim())).map((id) => id.trim());
62654
+ if (ids.length < matched.length) {
62655
+ const unusable = matched.filter((rule) => !(typeof rule.SecurityGroupRuleId === "string" && SG_RULE_ID_PATTERN.test(rule.SecurityGroupRuleId.trim()))).map((rule) => typeof rule.SecurityGroupRuleId === "string" ? `'${rule.SecurityGroupRuleId}'` : "<absent>");
62656
+ const readableNote = ids.length > 0 ? ` The readable candidates cdkd did see: ${ids.map((id) => `'${id}'`).join(", ")}.` : "";
62657
+ const ambiguityNote = matched.length > 1 ? ` Note that ${matched.length} rules matching one row is an ambiguity in its own right, which cdkd could not resolve even if every id were readable: ${SG_INGRESS_AMBIGUITY_REMEDY}${readableNote}` : "";
62658
+ throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}', and a live DescribeSecurityGroupRules lookup found ${matched.length} ingress rule(s) matching ${tupleNote}, but ${unusable.length} of them carry no usable 'sgr-...' rule id (AWS reported ${unusable.join(", ")}). cdkd adopts an id only on an EXACTLY ONE match, and that is a count of MATCHING RULES — so discarding the unreadable ones first would let a genuine two-match pass as "exactly one" and record an identifier naming the wrong rule. cdkd refuses instead. This is a DescribeSecurityGroupRules response shape cdkd does not expect; please open a cdkd issue if it persists.${ambiguityNote} ${sgIngressExportEscapeHatch(logicalId)}`);
62659
+ }
62660
+ if (ids.length === 1) return ids[0];
62661
+ if (ids.length === 0) throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}' (the rule was deployed by a cdkd older than https://github.com/go-to-k/cdkd/issues/1761), and a live DescribeSecurityGroupRules lookup found NO ingress rule matching ${tupleNote} — so cdkd has no id to hand CloudFormation IMPORT. Either the rule was revoked outside cdkd, or the group lives in a region other than the one this export is running against. Run cdkd deploy to reconcile the stack, then re-run cdkd export. ${sgIngressExportEscapeHatch(logicalId)}`);
62662
+ throw new Error(`cdkd state records no 'sgr-...' security-group rule id for '${logicalId}', and a live DescribeSecurityGroupRules lookup found ${ids.length} ingress rules matching ${tupleNote} (${ids.join(", ")}). cdkd's physical id identifies a rule only by group, protocol and port range, so it cannot say which of them '${logicalId}' is, and CloudFormation IMPORT needs exactly one — adopting either would record the wrong rule. ${SG_INGRESS_AMBIGUITY_REMEDY} ${sgIngressExportEscapeHatch(logicalId)}`);
62663
+ }
62664
+ /**
62665
+ * Resolve a registered type's CFn identifier VALUE, consulting AWS only when
62666
+ * cdkd state could not answer (issue
62667
+ * [#1791](https://github.com/go-to-k/cdkd/issues/1791)).
62668
+ *
62669
+ * The ordering is the whole design: `resolve` is tried FIRST and its throw is
62670
+ * the only trigger for the live read, so a row whose state already carries the
62671
+ * identifier — every row written by a current cdkd — issues no AWS call at all,
62672
+ * and a caller with no {@link IdentifierBackfillDeps} keeps exactly the
62673
+ * pre-#1791 behavior (the state-only refusal, verbatim).
62674
+ *
62675
+ * The backfill's own error REPLACES the state-only one when it fires: by then
62676
+ * cdkd has asked AWS, so the state-only message's central remedy ("re-deploy so
62677
+ * cdkd records the id") is not merely unhelpful but has been disproven for this
62678
+ * row — the live read already found zero, or too many.
62679
+ */
62680
+ async function resolveIdentifierValue(entry, ctx, deps) {
62681
+ try {
62682
+ return entry.resolve(ctx);
62683
+ } catch (stateOnlyError) {
62684
+ if (!entry.backfill || !deps) throw stateOnlyError;
62685
+ return await entry.backfill(ctx, deps);
62686
+ }
62687
+ }
62688
+ /**
62000
62689
  * Build the {@link CompositePhysicalIdIdentifier} for a type whose CFn
62001
62690
  * identifier is an ARN cdkd records in `attributes` under the SAME name as the
62002
62691
  * `primaryIdentifier` field.
@@ -62217,7 +62906,8 @@ async function exportCommand(stackArg, options) {
62217
62906
  }
62218
62907
  const { phase1Imports, phase2Creates, recreateBeforePhase2, nestedStackRows, blocked } = await buildImportPlan(state, template, awsClients.cloudFormation, resolvedStackName, {
62219
62908
  recreateImportUnsupported: options.recreateImportUnsupported,
62220
- skipImportSupportPreflight: options.skipImportSupportPreflight
62909
+ skipImportSupportPreflight: options.skipImportSupportPreflight,
62910
+ ec2Client: awsClients.ec2
62221
62911
  });
62222
62912
  if (blocked.length > 0) {
62223
62913
  logger.error("The following resources block migration:");
@@ -62259,6 +62949,7 @@ async function exportCommand(stackArg, options) {
62259
62949
  rootParameters: rootParametersForNested,
62260
62950
  deps: {
62261
62951
  cfnClient: awsClients.cloudFormation,
62952
+ ec2Client: awsClients.ec2,
62262
62953
  stateBackend,
62263
62954
  lockManager,
62264
62955
  uploadOpts: {
@@ -62753,6 +63444,7 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
62753
63444
  const nestedStackRows = [];
62754
63445
  const blocked = [];
62755
63446
  const identifierCache = /* @__PURE__ */ new Map();
63447
+ const backfillDeps = options.ec2Client ? createIdentifierBackfillDeps(options.ec2Client) : void 0;
62756
63448
  for (const [logicalId, raw] of Object.entries(templateResources)) {
62757
63449
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
62758
63450
  const resourceType = raw.Type ?? "";
@@ -62820,7 +63512,7 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
62820
63512
  });
62821
63513
  continue;
62822
63514
  }
62823
- resolved = resolveResourceIdentifier(resourceType, logicalId, stateEntry.physicalId, stateEntry.properties ?? {}, stateEntry.attributes ?? {}, schemaInfo);
63515
+ resolved = await resolveResourceIdentifier(resourceType, logicalId, stateEntry.physicalId, stateEntry.properties ?? {}, stateEntry.attributes ?? {}, schemaInfo, backfillDeps);
62824
63516
  } catch (err) {
62825
63517
  blocked.push({
62826
63518
  logicalId,
@@ -62907,15 +63599,15 @@ function groupBlockedReasons(blocked) {
62907
63599
  * what keeps `buildImportPlan` to ONE DescribeType per type across both the
62908
63600
  * IMPORT pre-flight and this resolution.
62909
63601
  */
62910
- function resolveResourceIdentifier(resourceType, logicalId, physicalId, properties, attributes, entry) {
63602
+ async function resolveResourceIdentifier(resourceType, logicalId, physicalId, properties, attributes, entry, backfillDeps) {
62911
63603
  const compositeIdentifier = Object.hasOwn(COMPOSITE_PHYSICAL_ID_IDENTIFIERS, resourceType) ? COMPOSITE_PHYSICAL_ID_IDENTIFIERS[resourceType] : void 0;
62912
63604
  if (compositeIdentifier) {
62913
63605
  if (entry.fields.length !== 1 || entry.fields[0] !== compositeIdentifier.field) throw new Error(`${resourceType} is registered in COMPOSITE_PHYSICAL_ID_IDENTIFIERS as identified by '${compositeIdentifier.field}', but CloudFormation now reports primaryIdentifier [${entry.fields.join(", ")}]. The registry schema changed under cdkd — update the entry in src/cli/commands/export.ts (or move the type to COMPOSITE_ID_SPLITTERS if its identifier is now composite too)`);
62914
- const value = compositeIdentifier.resolve({
63606
+ const value = await resolveIdentifierValue(compositeIdentifier, {
62915
63607
  logicalId,
62916
63608
  physicalId,
62917
63609
  attributes
62918
- });
63610
+ }, backfillDeps);
62919
63611
  return {
62920
63612
  resourceIdentifier: { [compositeIdentifier.field]: value },
62921
63613
  propertiesOverlay: {}
@@ -64077,7 +64769,8 @@ async function runPerStackImportLoop(args) {
64077
64769
  const meta = nodesByCdkdName.get(node.stackName);
64078
64770
  const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, {
64079
64771
  recreateImportUnsupported: options.recreateImportUnsupported,
64080
- skipImportSupportPreflight: options.skipImportSupportPreflight === true
64772
+ skipImportSupportPreflight: options.skipImportSupportPreflight === true,
64773
+ ...deps.ec2Client && { ec2Client: deps.ec2Client }
64081
64774
  });
64082
64775
  if (plan.blocked.length > 0) {
64083
64776
  const lines = groupBlockedReasons(plan.blocked);
@@ -79087,7 +79780,7 @@ function createMigrateCommand() {
79087
79780
  */
79088
79781
  function buildProgram() {
79089
79782
  const program = new Command();
79090
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.25");
79783
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.27");
79091
79784
  program.hook("preAction", (_thisCommand, actionCommand) => {
79092
79785
  const { profile } = actionCommand.optsWithGlobals();
79093
79786
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;