@go-to-k/cdkd 0.280.32 → 0.280.34

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 requireConfigObject, $t as AssemblyReader, A as green, At as runDockerForeground, B as disableInstanceApiTermination, Bt as resolveCaptureObservedState, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as StackTerminationProtectionError, Ct as getBootstrapMarkerKey, D as bold, Dt as buildDockerImage, E as formatResourceLine, Et as validateContainerRepoName, F as clearOnUpdateRemoval, Ft as synthesisStatusMessage, G as WAFv2WebACLProvider, Gt as stateBucketExistenceConfirmed, H as IntrinsicFunctionResolver, Ht as resolveStateBucketWithDefault, I as ProviderRegistry, It as getDefaultStateBucketName, J as assertRegionMatch, Jt as CFN_TEMPLATE_URL_LIMIT, K as normalizeAwsTagsToCfn, Kt as warnDeprecatedNoPrefixCliFlag, L as findActionableSilentDrops, Lt as getLegacyStateBucketName, M as yellow, Mt as AssetManifestLoader, N as IAMRoleProvider, Nt as getDockerImageBySourceHash, O as cyan, On as normalizeAwsError, Ot as formatDockerLoginError, P as collectInlinePolicyNamesManagedBySiblings, Pt as Synthesizer, Q as requireConfigArray, Qt as expectedOwnerParam, R as CloudControlProvider, Rt as resolveApp, S as extractDeploymentEventError, Sn as StackHasActiveImportsError, St as ensureAssetStorage, T as renderStatefulReason, Tt as validateAssetBucketName, U as cfnRefValueFromPhysicalId, Ut as resolveStateBucketWithDefaultAndSource, V as isTerminationProtectionPropagationError, Vt as resolveSkipPrefix, W as refStateLookupFromResource, Wt as resolveUseCdkBootstrapAssets, X as readConfigString, Xt as findLargeInlineResources, Y as configStringRefusal, Yt as MIGRATE_TMP_PREFIX, Z as replayWarn, Zt as uploadCfnTemplate, _ as createPreDeleteFinalSnapshot, _n as NestedStackChildDirectDestroyError, _t as createAssetRedirectResolver, a as DeploymentEventsStore, an as resetAwsClients, at as isRetryableTransientError, b as unsupportedFinalSnapshotError, bn as ResourceTimeoutError, bt as AssetModeResolver, c as replayFailedOperations, cn as CdkdError, ct as TemplateParser, d as IMPLICIT_DELETE_DEPENDENCIES, dn as DeployCancelledError, dt as rebuildClientForBucketRegion, en as processStackMessages, et as requireConfigString, f as computeImplicitDeleteEdges, fn as LocalInvokeBuildError$1, ft as shouldRetainResource, g as ccRoutedFinalSnapshotError, gn as MissingCdkCliError, gt as buildAssetRedirectMap, h as buildFinalSnapshotIdentifier, ht as WorkGraph, i as DeploymentEventsReader, in as getAwsClients, it as withRetry, j as red, jt as runDockerStreaming, k as gray, kn as withErrorHandling, kt as getDockerCmd, l as replayRollback, lt as LockManager, m as PRE_DELETE_SNAPSHOT_TYPES, mn as LocalStartServiceError, mt as stringifyValue, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveBucketRegion, nt as DiffCalculator, o as planFailedOps, on as setAwsClients, ot as isThrottlingError, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as LocalMigrateError, pt as AssetPublisher, q as resolveExplicitPhysicalId, qt as CFN_TEMPLATE_BODY_LIMIT, r as DeployEngine, rn as AwsClients, rt as describeTypeWithThrottleRetry, s as planRollback, st as DagBuilder, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as applyRoleArnIfSet, u as withResourceDeadline, ut as S3StateBackend, v as isFinalSnapshotError, vn as PartialFailureError, vt as loadPublishableAssetManifest, w as isStatefulRecreateTargetSync, wt as parseBootstrapMarker, x as makeCanonicalizePropertiesFn, xn as ResourceUpdateNotSupportedError, xt as BOOTSTRAP_MARKER_PREFIX, y as refusesFinalSnapshot, yn as ProvisioningError, yt as rewriteTemplateAssetReferences, z as slowCcOperationTimeoutMs, zt as resolveAutoAssetStorage } from "./deploy-engine-REAC5UGa.js";
2
+ import { $ as requireConfigObject, $t as AssemblyReader, A as green, At as runDockerForeground, B as disableInstanceApiTermination, Bt as resolveCaptureObservedState, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as StackTerminationProtectionError, Ct as getBootstrapMarkerKey, D as bold, Dt as buildDockerImage, E as formatResourceLine, Et as validateContainerRepoName, F as clearOnUpdateRemoval, Ft as synthesisStatusMessage, G as WAFv2WebACLProvider, Gt as stateBucketExistenceConfirmed, H as IntrinsicFunctionResolver, Ht as resolveStateBucketWithDefault, I as ProviderRegistry, It as getDefaultStateBucketName, J as assertRegionMatch, Jt as CFN_TEMPLATE_URL_LIMIT, K as normalizeAwsTagsToCfn, Kt as warnDeprecatedNoPrefixCliFlag, L as findActionableSilentDrops, Lt as getLegacyStateBucketName, M as yellow, Mt as AssetManifestLoader, N as IAMRoleProvider, Nt as getDockerImageBySourceHash, O as cyan, On as normalizeAwsError, Ot as formatDockerLoginError, P as collectInlinePolicyNamesManagedBySiblings, Pt as Synthesizer, Q as requireConfigArray, Qt as expectedOwnerParam, R as CloudControlProvider, Rt as resolveApp, S as extractDeploymentEventError, Sn as StackHasActiveImportsError, St as ensureAssetStorage, T as renderStatefulReason, Tt as validateAssetBucketName, U as cfnRefValueFromPhysicalId, Ut as resolveStateBucketWithDefaultAndSource, V as isTerminationProtectionPropagationError, Vt as resolveSkipPrefix, W as refStateLookupFromResource, Wt as resolveUseCdkBootstrapAssets, X as readConfigString, Xt as findLargeInlineResources, Y as configStringRefusal, Yt as MIGRATE_TMP_PREFIX, Z as replayWarn, Zt as uploadCfnTemplate, _ as createPreDeleteFinalSnapshot, _n as NestedStackChildDirectDestroyError, _t as createAssetRedirectResolver, a as DeploymentEventsStore, an as resetAwsClients, at as isRetryableTransientError, b as unsupportedFinalSnapshotError, bn as ResourceTimeoutError, bt as AssetModeResolver, c as replayFailedOperations, cn as CdkdError, ct as TemplateParser, d as IMPLICIT_DELETE_DEPENDENCIES, dn as DeployCancelledError, dt as rebuildClientForBucketRegion, en as processStackMessages, et as requireConfigString, f as computeImplicitDeleteEdges, fn as LocalInvokeBuildError$1, ft as shouldRetainResource, g as ccRoutedFinalSnapshotError, gn as MissingCdkCliError, gt as buildAssetRedirectMap, h as buildFinalSnapshotIdentifier, ht as WorkGraph, i as DeploymentEventsReader, in as getAwsClients, it as withRetry, j as red, jt as runDockerStreaming, k as gray, kn as withErrorHandling, kt as getDockerCmd, l as replayRollback, lt as LockManager, m as PRE_DELETE_SNAPSHOT_TYPES, mn as LocalStartServiceError, mt as stringifyValue, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveBucketRegion, nt as DiffCalculator, o as planFailedOps, on as setAwsClients, ot as isThrottlingError, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as LocalMigrateError, pt as AssetPublisher, q as resolveExplicitPhysicalId, qt as CFN_TEMPLATE_BODY_LIMIT, r as DeployEngine, rn as AwsClients, rt as describeTypeWithThrottleRetry, s as planRollback, st as DagBuilder, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as applyRoleArnIfSet, u as withResourceDeadline, ut as S3StateBackend, v as isFinalSnapshotError, vn as PartialFailureError, vt as loadPublishableAssetManifest, w as isStatefulRecreateTargetSync, wt as parseBootstrapMarker, x as makeCanonicalizePropertiesFn, xn as ResourceUpdateNotSupportedError, xt as BOOTSTRAP_MARKER_PREFIX, y as refusesFinalSnapshot, yn as ProvisioningError, yt as rewriteTemplateAssetReferences, z as slowCcOperationTimeoutMs, zt as resolveAutoAssetStorage } from "./deploy-engine-DYDL4JxX.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-l4OlfWQo.js";
4
+ import { t as ASGProvider } from "./asg-provider-Gk-TB9L0.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";
@@ -11930,6 +11930,55 @@ function hasUsableTableCapacity(value) {
11930
11930
  return true;
11931
11931
  }
11932
11932
  /**
11933
+ * A capacity member, or `undefined` when the value is not a usable number.
11934
+ *
11935
+ * `Number()` coercion is NOT good enough here and the first draft of this
11936
+ * helper shipped that bug: `Number(null)`, `Number('')`, `Number([])` and
11937
+ * `Number(false)` are all **0**, not `NaN` — so a live `0` would have compared
11938
+ * EQUAL to a desired `null` / `''` / `[]` / `false` and suppressed the call.
11939
+ * The blast radius on DynamoDB is small (a PROVISIONED index's capacity is
11940
+ * always >= 1), but the rule this encodes is copied to other providers, so it
11941
+ * is spelled correctly rather than relying on the type that happens to be
11942
+ * unreachable today.
11943
+ *
11944
+ * A YAML-borne numeric STRING is still accepted, because that is a real
11945
+ * template shape and `'5'` genuinely means 5.
11946
+ */
11947
+ function capacityNumber(value) {
11948
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
11949
+ if (typeof value === "string" && value.trim() !== "") {
11950
+ const n = Number(value);
11951
+ return Number.isFinite(n) ? n : void 0;
11952
+ }
11953
+ }
11954
+ /**
11955
+ * Does the capacity AWS currently holds for a GSI already equal the pair the
11956
+ * template requests? (issue #1630)
11957
+ *
11958
+ * Compared member by member, deliberately not with `deepEqual` /
11959
+ * `JSON.stringify`: `DescribeTable` returns a `ProvisionedThroughputDescription`
11960
+ * carrying `NumberOfDecreasesToday` / `LastIncreaseDateTime` /
11961
+ * `LastDecreaseDateTime` alongside the two capacities, so a structural compare
11962
+ * against the two-member desired object could never match and the suppression
11963
+ * would be dead code that only LOOKED safe.
11964
+ *
11965
+ * Anything that is not a usable number on EITHER side fails OPEN (the
11966
+ * `UpdateTable` is still issued): an absent live entry, a malformed template
11967
+ * value, an unresolved intrinsic. That is the correct direction — the worst
11968
+ * case is the pre-fix behavior, whereas a false MATCH would silently drop a
11969
+ * capacity change the user asked for.
11970
+ */
11971
+ function liveCapacityAlreadyMatches(live, requested) {
11972
+ if (live === void 0) return false;
11973
+ const liveRead = capacityNumber(live.ReadCapacityUnits);
11974
+ const liveWrite = capacityNumber(live.WriteCapacityUnits);
11975
+ const wantRead = capacityNumber(requested.ReadCapacityUnits);
11976
+ const wantWrite = capacityNumber(requested.WriteCapacityUnits);
11977
+ if (liveRead === void 0 || liveWrite === void 0) return false;
11978
+ if (wantRead === void 0 || wantWrite === void 0) return false;
11979
+ return liveRead === wantRead && liveWrite === wantWrite;
11980
+ }
11981
+ /**
11933
11982
  * `DescribeTable` poll budget (seconds, 1 poll/s) after an ordinary
11934
11983
  * `UpdateTable` — capacity, TTL, tags, table class. Calibrated on those and
11935
11984
  * sufficient for them.
@@ -12105,6 +12154,8 @@ var DynamoDBTableProvider = class {
12105
12154
  }
12106
12155
  }
12107
12156
  const currentLiveIndexNames = table ? new Set(liveIndexes.map((live) => live.IndexName).filter((name) => typeof name === "string").filter((name) => !preFlipDeletedIndexNames.has(name))) : void 0;
12157
+ const currentLiveIndexByName = table ? new Map(liveIndexes.filter((live) => typeof live.IndexName === "string").filter((live) => !preFlipDeletedIndexNames.has(live.IndexName)).map((live) => [live.IndexName, live])) : void 0;
12158
+ const liveCapacityComparable = liveBillingMode === "PROVISIONED";
12108
12159
  const gsiHandledByBillingFlip = /* @__PURE__ */ new Set();
12109
12160
  if (billingOrThroughputChanged || tableClassChanged) {
12110
12161
  const updateInput = { TableName: physicalId };
@@ -12219,7 +12270,7 @@ var DynamoDBTableProvider = class {
12219
12270
  this.logger.debug(`Disabled DynamoDB Stream on table ${physicalId}`);
12220
12271
  }
12221
12272
  }
12222
- if (JSON.stringify(properties["GlobalSecondaryIndexes"]) !== JSON.stringify(previousProperties["GlobalSecondaryIndexes"])) await this.applyGsiUpdates(physicalId, resourceType, logicalId, previousProperties["GlobalSecondaryIndexes"], properties["GlobalSecondaryIndexes"], properties["AttributeDefinitions"], gsiHandledByBillingFlip, currentLiveIndexNames);
12273
+ if (JSON.stringify(properties["GlobalSecondaryIndexes"]) !== JSON.stringify(previousProperties["GlobalSecondaryIndexes"])) await this.applyGsiUpdates(physicalId, resourceType, logicalId, previousProperties["GlobalSecondaryIndexes"], properties["GlobalSecondaryIndexes"], properties["AttributeDefinitions"], gsiHandledByBillingFlip, currentLiveIndexNames, currentLiveIndexByName, liveCapacityComparable);
12223
12274
  if (JSON.stringify(properties["PointInTimeRecoverySpecification"]) !== JSON.stringify(previousProperties["PointInTimeRecoverySpecification"])) await this.applyPointInTimeRecovery(physicalId, properties["PointInTimeRecoverySpecification"], previousProperties["PointInTimeRecoverySpecification"]);
12224
12275
  if (JSON.stringify(properties["TimeToLiveSpecification"]) !== JSON.stringify(previousProperties["TimeToLiveSpecification"])) {
12225
12276
  this.assertNoActiveTtlAttributeNameChange(logicalId, resourceType, physicalId, properties["TimeToLiveSpecification"], previousProperties["TimeToLiveSpecification"]);
@@ -12657,7 +12708,7 @@ var DynamoDBTableProvider = class {
12657
12708
  * a remove-then-add pair, so this method throws on such a change rather than
12658
12709
  * silently dropping it.
12659
12710
  */
12660
- async applyGsiUpdates(physicalId, resourceType, logicalId, previousGsis, desiredGsis, desiredAttributeDefinitions, handledByBillingFlip = /* @__PURE__ */ new Set(), liveIndexNames) {
12711
+ async applyGsiUpdates(physicalId, resourceType, logicalId, previousGsis, desiredGsis, desiredAttributeDefinitions, handledByBillingFlip = /* @__PURE__ */ new Set(), liveIndexNames, liveIndexByName, liveCapacityComparable = false) {
12661
12712
  const prev = previousGsis ?? [];
12662
12713
  const desired = desiredGsis ?? [];
12663
12714
  const prevByName = new Map(prev.filter((g) => g.IndexName).map((g) => [g.IndexName, g]));
@@ -12672,7 +12723,17 @@ var DynamoDBTableProvider = class {
12672
12723
  ops.push({ Delete: { IndexName: name } });
12673
12724
  }
12674
12725
  for (const [name, gsi] of desiredByName) if (!prevByName.has(name)) {
12726
+ const live = liveIndexByName?.get(name);
12675
12727
  if (!gsi.KeySchema) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} is missing KeySchema`, resourceType, logicalId, physicalId);
12728
+ if (live !== void 0 && live.IndexStatus !== "DELETING") {
12729
+ this.logger.warn(`GSI ${name} on DynamoDB table ${physicalId} already exists in AWS but is absent from cdkd's recorded previous state, so its Create is being skipped. This usually means an earlier deploy created the index and then failed before state was written.`);
12730
+ if (liveCapacityComparable && gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && !liveCapacityAlreadyMatches(live.ProvisionedThroughput, gsi.ProvisionedThroughput)) ops.push({ Update: {
12731
+ IndexName: name,
12732
+ ProvisionedThroughput: gsi.ProvisionedThroughput
12733
+ } });
12734
+ this.logger.warn(`GSI ${name} was adopted from AWS rather than created, so cdkd could not verify its KeySchema / Projection match the template. Run \`cdkd drift ${physicalId}\` to confirm, and \`cdkd drift --accept\` if AWS is the source of truth.`);
12735
+ continue;
12736
+ }
12676
12737
  ops.push({ Create: {
12677
12738
  IndexName: name,
12678
12739
  KeySchema: gsi.KeySchema,
@@ -12683,7 +12744,8 @@ var DynamoDBTableProvider = class {
12683
12744
  } else {
12684
12745
  const before = prevByName.get(name);
12685
12746
  if (JSON.stringify(before.KeySchema) !== JSON.stringify(gsi.KeySchema) || JSON.stringify(before.Projection) !== JSON.stringify(gsi.Projection)) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} changed its KeySchema or Projection, which DynamoDB cannot modify in place. Rename the index (so it is dropped and re-created) or replace the table.`, resourceType, logicalId, physicalId);
12686
- if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) ops.push({ Update: {
12747
+ if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) this.logger.debug(`GSI ${name} on DynamoDB table ${physicalId} already carries the requested capacity in AWS; skipping its throughput Update`);
12748
+ else ops.push({ Update: {
12687
12749
  IndexName: name,
12688
12750
  ProvisionedThroughput: gsi.ProvisionedThroughput
12689
12751
  } });
@@ -17946,6 +18008,95 @@ var EventBridgeBusProvider = class {
17946
18008
  }
17947
18009
  };
17948
18010
 
18011
+ //#endregion
18012
+ //#region src/utils/ip-protocol.ts
18013
+ /**
18014
+ * EC2 `IpProtocol` value canonicalization (issue
18015
+ * [#1643](https://github.com/go-to-k/cdkd/issues/1643)).
18016
+ *
18017
+ * EC2 owns the spelling of a security-group rule's protocol: it accepts an
18018
+ * IANA protocol NUMBER or a protocol NAME, rewrites the four numbers it has a
18019
+ * name for before storing the rule, and lower-cases a name it is given. So the
18020
+ * value cdkd SENDS and the value AWS REPORTS are routinely two spellings of
18021
+ * ONE protocol.
18022
+ *
18023
+ * This lives in `src/utils/` rather than beside either consumer because it has
18024
+ * TWO, in different layers: `src/analyzer/drift-protocol-normalize.ts` (the
18025
+ * drift comparator) and `src/provisioning/providers/ec2-provider.ts` (rule
18026
+ * IDENTITY — the standalone-rule lookup, the inline-rule reconcile, and the
18027
+ * write-side revoke/authorize diff). Hosting it in the analyzer made the
18028
+ * provider carry the tree's only `src/provisioning/**` -> `src/analyzer/**`
18029
+ * import, which is both a layering break and a real hazard: an edit made for
18030
+ * the drift comparator would silently change which AWS rule a revoke matches.
18031
+ *
18032
+ * ## The rename set is MEASURED, not guessed
18033
+ *
18034
+ * Probed against real AWS (us-east-1, 2026-08-12) by authorizing one rule per
18035
+ * protocol on a scratch security group and reading `IpProtocol` back, for
18036
+ * ingress AND egress:
18037
+ *
18038
+ * - RENAMED: `1` -> `icmp`, `6` -> `tcp`, `17` -> `udp`, `58` -> `icmpv6`.
18039
+ * - NOT renamed: `-1`, `0`, `2`, `4`, `27`, `41`, `47`, `50`, `51`, `88`,
18040
+ * `89`, `94`, `103`, `112`, `132`, `255` — all read back as the number.
18041
+ *
18042
+ * So the rename set is exactly the four protocols EC2 has a NAME for — a
18043
+ * CLOSED set, not the open-ended IANA list the issue worried about. `-1` is
18044
+ * unaffected, which is why #1633's live test came back clean.
18045
+ *
18046
+ * Case is also AWS's to decide: `TCP` / `Tcp` / `tcp` authorized on the same
18047
+ * port collapsed into ONE `tcp` permission in the readback, so a name is
18048
+ * matched case-insensitively and canonicalized to lower case. Only the four
18049
+ * KNOWN names are lowered — an unrecognized string is left exactly as-is
18050
+ * rather than folded, so this can never make two genuinely different values
18051
+ * compare equal.
18052
+ *
18053
+ * Known bound: only the CANONICAL decimal spelling is recognized. A
18054
+ * zero-padded `'06'` or a `'0x6'` misses the table and is returned verbatim,
18055
+ * so such a template still phantom-drifts. Neither form has been observed in
18056
+ * a CDK-synthesized template, and accepting them would mean parsing rather
18057
+ * than table lookup — which is what keeps the "closed, measured set" property
18058
+ * that makes this safe.
18059
+ */
18060
+ /**
18061
+ * The protocol numbers AWS rewrites to a name, measured (see the module
18062
+ * docstring). Keyed by the number's STRING form, since that is the shape every
18063
+ * input is normalized to first.
18064
+ */
18065
+ const PROTOCOL_NUMBER_TO_NAME = /* @__PURE__ */ new Map([
18066
+ ["1", "icmp"],
18067
+ ["6", "tcp"],
18068
+ ["17", "udp"],
18069
+ ["58", "icmpv6"]
18070
+ ]);
18071
+ /** The four names EC2 canonicalizes to, for the case-insensitive match. */
18072
+ const CANONICAL_PROTOCOL_NAMES = new Set(PROTOCOL_NUMBER_TO_NAME.values());
18073
+ /**
18074
+ * Canonicalize ONE `IpProtocol` value the way EC2 does.
18075
+ *
18076
+ * Returns the input unchanged for any shape that is not a protocol value — an
18077
+ * unresolved intrinsic object (`{Ref: ...}`), `null`, a boolean.
18078
+ *
18079
+ * EVERY number is stringified, not only an integer one: an unquoted YAML
18080
+ * `IpProtocol: 47` parses to a number while AWS always reads back a string, and
18081
+ * `ec2-provider.ts`'s rule-identity key has stringified every number since
18082
+ * #1633 specifically so a legacy record holding the NUMBER `-1` still matches
18083
+ * AWS's `'-1'`. Narrowing that to integers would have silently regressed a
18084
+ * malformed `6.5` from `'6.5'` to the number `6.5`, and collapsed `NaN` /
18085
+ * `Infinity` onto one JSON `null` bucket — the exact merging that key exists
18086
+ * to avoid. The rename lookup is a table hit, so a non-integer simply misses it.
18087
+ */
18088
+ function canonicalizeIpProtocolValue(value) {
18089
+ let text;
18090
+ if (typeof value === "string") text = value;
18091
+ else if (typeof value === "number") text = String(value);
18092
+ else return value;
18093
+ const renamed = PROTOCOL_NUMBER_TO_NAME.get(text);
18094
+ if (renamed !== void 0) return renamed;
18095
+ const lowered = text.toLowerCase();
18096
+ if (CANONICAL_PROTOCOL_NAMES.has(lowered)) return lowered;
18097
+ return text;
18098
+ }
18099
+
17949
18100
  //#endregion
17950
18101
  //#region src/provisioning/providers/ec2-provider.ts
17951
18102
  /**
@@ -19844,7 +19995,7 @@ var EC2Provider = class {
19844
19995
  const prefixKey = direction === "egress" ? rule["DestinationPrefixListId"] : rule["SourcePrefixListId"];
19845
19996
  const peerOwner = direction === "ingress" ? rule["SourceSecurityGroupOwnerId"] : void 0;
19846
19997
  return JSON.stringify({
19847
- p: rule["IpProtocol"] ?? "-1",
19998
+ p: sgProtocolKey(rule["IpProtocol"]),
19848
19999
  f: rule["FromPort"] ?? null,
19849
20000
  t: rule["ToPort"] ?? null,
19850
20001
  c4: rule["CidrIp"] ?? null,
@@ -20663,7 +20814,8 @@ var EC2Provider = class {
20663
20814
  const toPort = parts[3] === "-1" ? void 0 : parseInt(parts[3], 10);
20664
20815
  const sg = (await this.ec2Client.send(new DescribeSecurityGroupsCommand({ GroupIds: [groupId] }))).SecurityGroups?.[0];
20665
20816
  if (!sg) return void 0;
20666
- const candidates = (sg.IpPermissions ?? []).filter((p) => (p.IpProtocol ?? "-1") === protocol && (p.FromPort ?? void 0) === fromPort && (p.ToPort ?? void 0) === toPort);
20817
+ const wantedProtocol = sgProtocolKey(protocol);
20818
+ const candidates = (sg.IpPermissions ?? []).filter((p) => sgProtocolKey(p.IpProtocol) === wantedProtocol && (p.FromPort ?? void 0) === fromPort && (p.ToPort ?? void 0) === toPort);
20667
20819
  if (candidates.length === 0) return void 0;
20668
20820
  const flat = flattenIpPermissions(candidates, "ingress");
20669
20821
  if (properties && Object.keys(properties).length > 0) {
@@ -20884,8 +21036,7 @@ function flattenIpPermissions(perms, direction) {
20884
21036
  * non-match honest instead of manufacturing a fake identity.
20885
21037
  */
20886
21038
  function sgProtocolKey(value) {
20887
- if (typeof value === "number") return String(value);
20888
- return value ?? "-1";
21039
+ return canonicalizeIpProtocolValue(value ?? "-1");
20889
21040
  }
20890
21041
  function sgRuleKey(rule, direction) {
20891
21042
  const peerKey = direction === "egress" ? rule["DestinationSecurityGroupId"] : rule["SourceSecurityGroupId"];
@@ -52756,6 +52907,145 @@ async function canonicalizePrincipalUniqueIds(baseline, aws, resolve) {
52756
52907
  };
52757
52908
  }
52758
52909
 
52910
+ //#endregion
52911
+ //#region src/analyzer/drift-protocol-normalize.ts
52912
+ /**
52913
+ * EC2 security-group `IpProtocol` VALUE canonicalization for the drift
52914
+ * comparator (issue
52915
+ * [#1643](https://github.com/go-to-k/cdkd/issues/1643)) — a sibling of
52916
+ * `drift-normalize.ts` for a difference that is not about ORDER, in the same
52917
+ * spirit as `drift-principal-normalize.ts`.
52918
+ *
52919
+ * EC2 owns the spelling of `IpProtocol`: it accepts an IANA protocol NUMBER or
52920
+ * a protocol NAME, and rewrites a subset of the numbers to their canonical
52921
+ * name before storing the rule. cdkd records what it SENT (the template's
52922
+ * value, post-#1633), AWS holds the canonical name, and
52923
+ * `readSecurityGroupIngressCurrentState` reads the name back — so the two
52924
+ * sides are two spellings of ONE protocol and every `cdkd drift` run reports
52925
+ * the difference forever. `drift --revert` cannot clear it either: it revokes
52926
+ * and re-authorizes into the same state, which is the permanent phantom drift
52927
+ * the #1591 / #1633 fixes address for their own arms.
52928
+ *
52929
+ * Unlike the send-side narrowing #1633 does, this is a READBACK-normalization
52930
+ * concern, so it lives here and runs on BOTH comparison sides. That placement
52931
+ * is load-bearing for two reasons: the baseline may be the user's TEMPLATE
52932
+ * (a resource deployed before observed-capture), where the value is whatever
52933
+ * they wrote; and it covers the inline
52934
+ * `AWS::EC2::SecurityGroup.SecurityGroupIngress[]` / `SecurityGroupEgress[]`
52935
+ * rules, which #1633 deliberately does not touch.
52936
+ *
52937
+ * ## The rename set is MEASURED, not guessed
52938
+ *
52939
+ * Probed against real AWS (us-east-1, 2026-08-12) by authorizing one rule per
52940
+ * protocol on a scratch security group and reading `IpProtocol` back, for
52941
+ * ingress AND egress:
52942
+ *
52943
+ * - RENAMED: `1` -> `icmp`, `6` -> `tcp`, `17` -> `udp`, `58` -> `icmpv6`.
52944
+ * - NOT renamed: `-1`, `0`, `2`, `4`, `27`, `41`, `47`, `50`, `51`, `88`,
52945
+ * `89`, `94`, `103`, `112`, `132`, `255` — all read back as the number.
52946
+ *
52947
+ * So the rename set is exactly the four protocols EC2 has a NAME for, and it
52948
+ * is a CLOSED set rather than the open-ended IANA list the issue worried
52949
+ * about. `-1` is unaffected, which is why #1633's live test came back clean.
52950
+ *
52951
+ * The measured rename table, the case-folding rule and their bounds live with
52952
+ * the shared value canonicalizer in `src/utils/ip-protocol.ts` — this module
52953
+ * owns only WHERE in a resource's properties that canonicalization applies.
52954
+ *
52955
+ * **This pass matters for the `properties`-fallback baseline, not for a fresh
52956
+ * deploy.** A resource deployed by a current binary captures
52957
+ * `observedProperties` from the same `readCurrentState` the drift run uses, so
52958
+ * both sides already hold AWS's spelling and this is a no-op. The population it
52959
+ * is FOR is the one whose baseline is the user's TEMPLATE: state written before
52960
+ * observed-capture existed, or a provider that captures none. That is also why
52961
+ * the integ fixture has to strip `observedProperties` to exercise it — the
52962
+ * first version of this change asserted a fresh deploy would fail without the
52963
+ * module, and three independent reviews showed it would not.
52964
+ */
52965
+ /**
52966
+ * The resource types carrying an `IpProtocol` this pass rewrites, mapped to
52967
+ * the property path(s) it lives at. The standalone rule types carry it at the
52968
+ * top level; `AWS::EC2::SecurityGroup` carries it inside each element of its
52969
+ * two inline rule arrays.
52970
+ *
52971
+ * Deliberately a CLOSED table rather than a walk for any key named
52972
+ * `IpProtocol`: a blanket rewrite would turn an unrelated property whose value
52973
+ * happens to be `'6'` into `'tcp'`.
52974
+ */
52975
+ const IP_PROTOCOL_PATHS = /* @__PURE__ */ new Map([
52976
+ ["AWS::EC2::SecurityGroupIngress", ["IpProtocol"]],
52977
+ ["AWS::EC2::SecurityGroupEgress", ["IpProtocol"]],
52978
+ ["AWS::EC2::SecurityGroup", ["SecurityGroupIngress[].IpProtocol", "SecurityGroupEgress[].IpProtocol"]]
52979
+ ]);
52980
+ /**
52981
+ * Rewrite the `IpProtocol` at `path` inside `properties`, returning a copy
52982
+ * when anything changed and the original object otherwise.
52983
+ *
52984
+ * `path` is either a plain key (`'IpProtocol'`) or an array-crossing form
52985
+ * (`'SecurityGroupIngress[].IpProtocol'`) whose left half names an array
52986
+ * whose every element carries the key.
52987
+ */
52988
+ function rewriteAtPath(properties, path) {
52989
+ if (properties === null || typeof properties !== "object" || Array.isArray(properties)) return properties;
52990
+ const bag = properties;
52991
+ const arrayMarker = path.indexOf("[].");
52992
+ if (arrayMarker === -1) {
52993
+ if (!(path in bag)) return properties;
52994
+ const next = canonicalizeIpProtocolValue(bag[path]);
52995
+ if (next === bag[path]) return properties;
52996
+ return {
52997
+ ...bag,
52998
+ [path]: next
52999
+ };
53000
+ }
53001
+ const arrayKey = path.slice(0, arrayMarker);
53002
+ const elementKey = path.slice(arrayMarker + 3);
53003
+ const list = bag[arrayKey];
53004
+ if (!Array.isArray(list)) return properties;
53005
+ let changed = false;
53006
+ const mapped = list.map((element) => {
53007
+ if (element === null || typeof element !== "object" || Array.isArray(element)) return element;
53008
+ const rule = element;
53009
+ if (!(elementKey in rule)) return element;
53010
+ const next = canonicalizeIpProtocolValue(rule[elementKey]);
53011
+ if (next === rule[elementKey]) return element;
53012
+ changed = true;
53013
+ return {
53014
+ ...rule,
53015
+ [elementKey]: next
53016
+ };
53017
+ });
53018
+ if (!changed) return properties;
53019
+ return {
53020
+ ...bag,
53021
+ [arrayKey]: mapped
53022
+ };
53023
+ }
53024
+ /**
53025
+ * Canonicalize every `IpProtocol` on BOTH drift-comparison sides for the
53026
+ * security-group resource types that carry one.
53027
+ *
53028
+ * A resource type with no `IpProtocol` is returned untouched, so the caller
53029
+ * can invoke this unconditionally.
53030
+ */
53031
+ function canonicalizeIpProtocols(baseline, aws, resourceType) {
53032
+ const paths = IP_PROTOCOL_PATHS.get(resourceType);
53033
+ if (paths === void 0) return {
53034
+ baseline,
53035
+ aws
53036
+ };
53037
+ let nextBaseline = baseline;
53038
+ let nextAws = aws;
53039
+ for (const path of paths) {
53040
+ nextBaseline = rewriteAtPath(nextBaseline, path);
53041
+ nextAws = rewriteAtPath(nextAws, path);
53042
+ }
53043
+ return {
53044
+ baseline: nextBaseline,
53045
+ aws: nextAws
53046
+ };
53047
+ }
53048
+
52759
53049
  //#endregion
52760
53050
  //#region src/analyzer/drift-cc-api-deny-list.ts
52761
53051
  /**
@@ -53172,7 +53462,8 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
53172
53462
  const baseline = useObserved ? resource.observedProperties : resource.properties ?? {};
53173
53463
  const observedIgnorePaths = useObserved ? undeclaredEmptyObservedKeys(resource.observedProperties, resource.properties ?? {}) : [];
53174
53464
  const normalized = await canonicalizePrincipalUniqueIds(baseline, aws, resolvePrincipalUniqueId);
53175
- const changes = calculateResourceDrift(normalized.baseline, normalized.aws, {
53465
+ const protocolNormalized = canonicalizeIpProtocols(normalized.baseline, normalized.aws, resource.resourceType);
53466
+ const changes = calculateResourceDrift(protocolNormalized.baseline, protocolNormalized.aws, {
53176
53467
  ignorePaths: observedIgnorePaths.length ? [...ignorePaths, ...observedIgnorePaths] : ignorePaths,
53177
53468
  unionWalkObjects: useObserved,
53178
53469
  unorderedPaths
@@ -70922,7 +71213,7 @@ function createMigrateCommand() {
70922
71213
  */
70923
71214
  function buildProgram() {
70924
71215
  const program = new Command();
70925
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.280.32");
71216
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.280.34");
70926
71217
  program.hook("preAction", (_thisCommand, actionCommand) => {
70927
71218
  const { profile } = actionCommand.optsWithGlobals();
70928
71219
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;