@go-to-k/cdkd 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3533,6 +3533,60 @@ var TemplateParser = class {
3533
3533
  }
3534
3534
  };
3535
3535
 
3536
+ // src/analyzer/lambda-vpc-deps.ts
3537
+ function extractLambdaVpcDeleteDeps(resources) {
3538
+ const edges = [];
3539
+ const seen = /* @__PURE__ */ new Set();
3540
+ for (const [lambdaId, resource] of Object.entries(resources)) {
3541
+ if (resource.Type !== "AWS::Lambda::Function")
3542
+ continue;
3543
+ const vpcConfig = (resource.Properties ?? {})["VpcConfig"];
3544
+ if (!isObject(vpcConfig))
3545
+ continue;
3546
+ const targets = /* @__PURE__ */ new Set();
3547
+ collectRefIds(vpcConfig["SubnetIds"], targets);
3548
+ collectRefIds(vpcConfig["SecurityGroupIds"], targets);
3549
+ for (const targetId of targets) {
3550
+ if (targetId === lambdaId)
3551
+ continue;
3552
+ if (!(targetId in resources))
3553
+ continue;
3554
+ const key = `${lambdaId}\0${targetId}`;
3555
+ if (seen.has(key))
3556
+ continue;
3557
+ seen.add(key);
3558
+ edges.push({ before: lambdaId, after: targetId });
3559
+ }
3560
+ }
3561
+ return edges;
3562
+ }
3563
+ function isObject(v) {
3564
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3565
+ }
3566
+ function collectRefIds(value, out) {
3567
+ if (value === null || value === void 0)
3568
+ return;
3569
+ if (Array.isArray(value)) {
3570
+ for (const item of value)
3571
+ collectRefIds(item, out);
3572
+ return;
3573
+ }
3574
+ if (!isObject(value))
3575
+ return;
3576
+ if (typeof value["Ref"] === "string") {
3577
+ const ref = value["Ref"];
3578
+ if (!ref.startsWith("AWS::"))
3579
+ out.add(ref);
3580
+ return;
3581
+ }
3582
+ if (Array.isArray(value["Fn::GetAtt"])) {
3583
+ const arr = value["Fn::GetAtt"];
3584
+ if (typeof arr[0] === "string")
3585
+ out.add(arr[0]);
3586
+ return;
3587
+ }
3588
+ }
3589
+
3536
3590
  // src/analyzer/dag-builder.ts
3537
3591
  var { Graph, alg } = graphlib;
3538
3592
  var IAM_ROLE_POLICY_TYPES = /* @__PURE__ */ new Set([
@@ -3581,6 +3635,7 @@ var DagBuilder = class {
3581
3635
  }
3582
3636
  this.logger.debug(`Dependency graph built: ${resourceIds.length} nodes, ${edgeCount} edges`);
3583
3637
  edgeCount += this.addCustomResourcePolicyEdges(graph, template);
3638
+ edgeCount += this.addLambdaVpcEdges(graph, template);
3584
3639
  if (!alg.isAcyclic(graph)) {
3585
3640
  const cycles = this.findCycles(graph);
3586
3641
  throw new DependencyError(
@@ -3773,6 +3828,41 @@ var DagBuilder = class {
3773
3828
  }
3774
3829
  return added;
3775
3830
  }
3831
+ /**
3832
+ * Add edges from Subnets / SecurityGroups referenced by an
3833
+ * AWS::Lambda::Function VpcConfig to the Lambda itself.
3834
+ *
3835
+ * Same direction as a normal `Ref`-derived edge (Subnet -> Lambda), so for
3836
+ * deploy this just duplicates what extractDependencies already produced.
3837
+ * The point is robustness: if a future template massages the VpcConfig
3838
+ * shape in a way the recursive extractor doesn't anticipate, this pass
3839
+ * still ties the Lambda to its networking resources so that the
3840
+ * deletion-time reverse traversal continues to delete Lambda before
3841
+ * Subnet / SecurityGroup.
3842
+ *
3843
+ * Returns the number of NEW edges added (existing edges are skipped).
3844
+ */
3845
+ addLambdaVpcEdges(graph, template) {
3846
+ const edges = extractLambdaVpcDeleteDeps(template.Resources);
3847
+ if (edges.length === 0)
3848
+ return 0;
3849
+ let added = 0;
3850
+ for (const edge of edges) {
3851
+ const depId = edge.after;
3852
+ const dependentId = edge.before;
3853
+ if (!graph.hasNode(depId) || !graph.hasNode(dependentId))
3854
+ continue;
3855
+ if (graph.hasEdge(depId, dependentId))
3856
+ continue;
3857
+ graph.setEdge(depId, dependentId);
3858
+ added++;
3859
+ this.logger.debug(`Added implicit edge (lambda vpc): ${depId} -> ${dependentId}`);
3860
+ }
3861
+ if (added > 0) {
3862
+ this.logger.debug(`Added ${added} implicit edges for Lambda VpcConfig`);
3863
+ }
3864
+ return added;
3865
+ }
3776
3866
  isCustomResourceType(type) {
3777
3867
  return type === "AWS::CloudFormation::CustomResource" || type.startsWith("Custom::");
3778
3868
  }
@@ -4688,8 +4778,8 @@ var IntrinsicFunctionResolver = class {
4688
4778
  return resource.attributes?.["CidrBlock"] || resource.properties?.["CidrBlock"];
4689
4779
  case "Ipv6CidrBlocks": {
4690
4780
  try {
4691
- const { EC2Client: EC2Client8, DescribeVpcsCommand: DescribeVpcsCommand3 } = await import("@aws-sdk/client-ec2");
4692
- const ec2 = new EC2Client8({ region: this.resolverRegion });
4781
+ const { EC2Client: EC2Client9, DescribeVpcsCommand: DescribeVpcsCommand3 } = await import("@aws-sdk/client-ec2");
4782
+ const ec2 = new EC2Client9({ region: this.resolverRegion });
4693
4783
  const maxAttempts = 15;
4694
4784
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4695
4785
  const resp = await ec2.send(new DescribeVpcsCommand3({ VpcIds: [physicalId] }));
@@ -10897,9 +10987,11 @@ import {
10897
10987
  GetFunctionCommand,
10898
10988
  ResourceNotFoundException
10899
10989
  } from "@aws-sdk/client-lambda";
10990
+ import { DescribeNetworkInterfacesCommand } from "@aws-sdk/client-ec2";
10900
10991
  init_aws_clients();
10901
10992
  var LambdaFunctionProvider = class {
10902
10993
  lambdaClient;
10994
+ ec2Client;
10903
10995
  logger = getLogger().child("LambdaFunctionProvider");
10904
10996
  handledProperties = /* @__PURE__ */ new Map([
10905
10997
  [
@@ -10919,13 +11011,22 @@ var LambdaFunctionProvider = class {
10919
11011
  "Architectures",
10920
11012
  "PackageType",
10921
11013
  "TracingConfig",
10922
- "EphemeralStorage"
11014
+ "EphemeralStorage",
11015
+ "VpcConfig"
10923
11016
  ])
10924
11017
  ]
10925
11018
  ]);
11019
+ // ENI detach polling configuration (overridable for tests).
11020
+ // Lambda VPC ENI detach is async and can take 20-40 minutes in the worst case;
11021
+ // we poll up to 10 minutes and then warn-and-continue, since downstream Subnet/SG
11022
+ // deletion has its own retry logic that handles a small remaining window.
11023
+ eniWaitTimeoutMs = 10 * 60 * 1e3;
11024
+ eniWaitInitialDelayMs = 1e4;
11025
+ eniWaitMaxDelayMs = 3e4;
10926
11026
  constructor() {
10927
11027
  const awsClients = getAwsClients();
10928
11028
  this.lambdaClient = awsClients.lambda;
11029
+ this.ec2Client = awsClients.ec2;
10929
11030
  }
10930
11031
  /**
10931
11032
  * Create a Lambda function
@@ -10973,6 +11074,7 @@ var LambdaFunctionProvider = class {
10973
11074
  PackageType: properties["PackageType"],
10974
11075
  TracingConfig: properties["TracingConfig"],
10975
11076
  EphemeralStorage: properties["EphemeralStorage"],
11077
+ VpcConfig: this.buildVpcConfig(properties["VpcConfig"]),
10976
11078
  Tags: tags
10977
11079
  };
10978
11080
  const response = await this.lambdaClient.send(new CreateFunctionCommand(createParams));
@@ -11011,7 +11113,8 @@ var LambdaFunctionProvider = class {
11011
11113
  "Environment",
11012
11114
  "Layers",
11013
11115
  "TracingConfig",
11014
- "EphemeralStorage"
11116
+ "EphemeralStorage",
11117
+ "VpcConfig"
11015
11118
  ];
11016
11119
  let hasConfigChanges = false;
11017
11120
  for (const field of configFields) {
@@ -11032,7 +11135,11 @@ var LambdaFunctionProvider = class {
11032
11135
  Environment: properties["Environment"],
11033
11136
  Layers: properties["Layers"],
11034
11137
  TracingConfig: properties["TracingConfig"],
11035
- EphemeralStorage: properties["EphemeralStorage"]
11138
+ EphemeralStorage: properties["EphemeralStorage"],
11139
+ VpcConfig: this.buildVpcConfigForUpdate(
11140
+ properties["VpcConfig"],
11141
+ previousProperties["VpcConfig"]
11142
+ )
11036
11143
  };
11037
11144
  await this.lambdaClient.send(new UpdateFunctionConfigurationCommand(configParams));
11038
11145
  this.logger.debug(`Updated configuration for Lambda function ${physicalId}`);
@@ -11076,9 +11183,19 @@ var LambdaFunctionProvider = class {
11076
11183
  }
11077
11184
  /**
11078
11185
  * Delete a Lambda function
11186
+ *
11187
+ * For VPC-enabled Lambda functions, AWS detaches the hyperplane ENIs
11188
+ * asynchronously after DeleteFunction returns. If we let downstream
11189
+ * resource deletion (Subnet / SecurityGroup) proceed immediately, those
11190
+ * deletions fail with "has dependencies" / "has a dependent object".
11191
+ *
11192
+ * To smooth this out, when properties carry a VpcConfig with subnets or
11193
+ * security groups, we poll DescribeNetworkInterfaces for the function's
11194
+ * managed ENIs and only return once they are gone (or the timeout elapses).
11079
11195
  */
11080
- async delete(logicalId, physicalId, resourceType, _properties) {
11196
+ async delete(logicalId, physicalId, resourceType, properties) {
11081
11197
  this.logger.debug(`Deleting Lambda function ${logicalId}: ${physicalId}`);
11198
+ const hasVpcConfig = this.hasVpcConfig(properties?.["VpcConfig"]);
11082
11199
  try {
11083
11200
  await this.lambdaClient.send(new DeleteFunctionCommand({ FunctionName: physicalId }));
11084
11201
  this.logger.debug(`Successfully deleted Lambda function ${logicalId}`);
@@ -11096,6 +11213,153 @@ var LambdaFunctionProvider = class {
11096
11213
  cause
11097
11214
  );
11098
11215
  }
11216
+ if (hasVpcConfig) {
11217
+ await this.waitForLambdaEnisDetached(physicalId);
11218
+ }
11219
+ }
11220
+ /**
11221
+ * Build Lambda VpcConfig parameter from CDK properties.
11222
+ *
11223
+ * Returns undefined when VpcConfig is unset, so the SDK leaves the function
11224
+ * outside any VPC. Returns an empty config (no subnets, no SGs) when caller
11225
+ * explicitly clears it on update — that detaches the function from its VPC.
11226
+ */
11227
+ buildVpcConfig(raw) {
11228
+ if (raw === void 0 || raw === null) {
11229
+ return void 0;
11230
+ }
11231
+ if (typeof raw !== "object") {
11232
+ return void 0;
11233
+ }
11234
+ const vpc = raw;
11235
+ const result = {};
11236
+ if (Array.isArray(vpc["SubnetIds"])) {
11237
+ result.SubnetIds = vpc["SubnetIds"];
11238
+ }
11239
+ if (Array.isArray(vpc["SecurityGroupIds"])) {
11240
+ result.SecurityGroupIds = vpc["SecurityGroupIds"];
11241
+ }
11242
+ if (typeof vpc["Ipv6AllowedForDualStack"] === "boolean") {
11243
+ result.Ipv6AllowedForDualStack = vpc["Ipv6AllowedForDualStack"];
11244
+ }
11245
+ return result;
11246
+ }
11247
+ /**
11248
+ * Build VpcConfig for an update call, accounting for VPC detach.
11249
+ *
11250
+ * UpdateFunctionConfiguration treats an absent VpcConfig as "no change",
11251
+ * so omitting it cannot move a function out of its existing VPC. To
11252
+ * detach we must explicitly send empty SubnetIds / SecurityGroupIds.
11253
+ */
11254
+ buildVpcConfigForUpdate(newRaw, previousRaw) {
11255
+ const next = this.buildVpcConfig(newRaw);
11256
+ if (next) {
11257
+ return next;
11258
+ }
11259
+ if (this.hasVpcConfig(previousRaw)) {
11260
+ return { SubnetIds: [], SecurityGroupIds: [] };
11261
+ }
11262
+ return void 0;
11263
+ }
11264
+ /**
11265
+ * Determine whether the function actually attaches to a VPC, i.e. has at
11266
+ * least one Subnet ID. A bare VpcConfig with empty arrays does not create
11267
+ * any ENIs, so we skip the wait in that case.
11268
+ */
11269
+ hasVpcConfig(raw) {
11270
+ if (raw === void 0 || raw === null || typeof raw !== "object") {
11271
+ return false;
11272
+ }
11273
+ const vpc = raw;
11274
+ const subnets = vpc["SubnetIds"];
11275
+ return Array.isArray(subnets) && subnets.length > 0;
11276
+ }
11277
+ /**
11278
+ * Poll DescribeNetworkInterfaces until the Lambda-managed ENIs for the
11279
+ * given function are gone, or the configured timeout elapses.
11280
+ *
11281
+ * Lambda VPC ENIs carry a Description like:
11282
+ * "AWS Lambda VPC ENI-<functionName>-<uuid>"
11283
+ * We match on a substring to be tolerant of format drift.
11284
+ *
11285
+ * Polling: starts at eniWaitInitialDelayMs (10s), exponential backoff up
11286
+ * to eniWaitMaxDelayMs (30s), bounded by eniWaitTimeoutMs (10min).
11287
+ *
11288
+ * Timeout is treated as a soft warning: detach can legitimately take 20-40
11289
+ * minutes in degraded conditions, and downstream Subnet/SG deletion has
11290
+ * its own retries to handle the residual window.
11291
+ */
11292
+ async waitForLambdaEnisDetached(functionName) {
11293
+ const start = Date.now();
11294
+ let delay = this.eniWaitInitialDelayMs;
11295
+ let attempt = 0;
11296
+ this.logger.debug(
11297
+ `Waiting for Lambda VPC ENIs to detach for function ${functionName} (timeout ${this.eniWaitTimeoutMs}ms)`
11298
+ );
11299
+ const descriptionNeedle = `AWS Lambda VPC ENI`;
11300
+ const functionNamePattern = new RegExp(`(^|-)${escapeRegExp(functionName)}(-|$)`);
11301
+ for (; ; ) {
11302
+ attempt++;
11303
+ let count;
11304
+ try {
11305
+ count = await this.countLambdaEnis(descriptionNeedle, functionNamePattern);
11306
+ } catch (error) {
11307
+ this.logger.warn(
11308
+ `DescribeNetworkInterfaces failed while waiting for Lambda ENIs of ${functionName}: ${error instanceof Error ? error.message : String(error)}`
11309
+ );
11310
+ count = -1;
11311
+ }
11312
+ if (count === 0) {
11313
+ this.logger.debug(
11314
+ `Lambda ENIs for ${functionName} fully detached after ${attempt} poll(s) / ${Date.now() - start}ms`
11315
+ );
11316
+ return;
11317
+ }
11318
+ const elapsed = Date.now() - start;
11319
+ if (elapsed >= this.eniWaitTimeoutMs) {
11320
+ this.logger.warn(
11321
+ `Timeout (${this.eniWaitTimeoutMs}ms) waiting for Lambda VPC ENIs of ${functionName} to detach (remaining: ${count >= 0 ? count : "unknown"}). Continuing \u2014 downstream Subnet/SG deletion will retry as needed.`
11322
+ );
11323
+ return;
11324
+ }
11325
+ const remaining = this.eniWaitTimeoutMs - elapsed;
11326
+ const sleepMs = Math.min(delay, remaining);
11327
+ await this.sleep(sleepMs);
11328
+ delay = Math.min(delay * 2, this.eniWaitMaxDelayMs);
11329
+ }
11330
+ }
11331
+ /**
11332
+ * Count remaining Lambda-managed ENIs for the given function, paginating
11333
+ * through DescribeNetworkInterfaces and filtering on Description substring.
11334
+ *
11335
+ * Server-side filter (`description`) does not support wildcards in EC2's API,
11336
+ * so we filter client-side after narrowing on `requester-id` + `status`.
11337
+ */
11338
+ async countLambdaEnis(descriptionNeedle, functionNamePattern) {
11339
+ let nextToken;
11340
+ let count = 0;
11341
+ do {
11342
+ const resp = await this.ec2Client.send(
11343
+ new DescribeNetworkInterfacesCommand({
11344
+ Filters: [
11345
+ // Lambda hyperplane ENIs are owned by the Lambda service principal.
11346
+ { Name: "requester-id", Values: ["*:awslambda_*"] }
11347
+ ],
11348
+ NextToken: nextToken
11349
+ })
11350
+ );
11351
+ for (const ni of resp.NetworkInterfaces ?? []) {
11352
+ const desc = ni.Description ?? "";
11353
+ if (desc.includes(descriptionNeedle) && functionNamePattern.test(desc)) {
11354
+ count++;
11355
+ }
11356
+ }
11357
+ nextToken = resp.NextToken;
11358
+ } while (nextToken);
11359
+ return count;
11360
+ }
11361
+ sleep(ms) {
11362
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
11099
11363
  }
11100
11364
  /**
11101
11365
  * Build Lambda Code parameter from CDK properties
@@ -11182,6 +11446,9 @@ var LambdaFunctionProvider = class {
11182
11446
  return (crc ^ 4294967295) >>> 0;
11183
11447
  }
11184
11448
  };
11449
+ function escapeRegExp(input) {
11450
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11451
+ }
11185
11452
 
11186
11453
  // src/provisioning/providers/lambda-permission-provider.ts
11187
11454
  import {
@@ -13505,6 +13772,8 @@ import {
13505
13772
  DeleteSecurityGroupCommand,
13506
13773
  AuthorizeSecurityGroupIngressCommand,
13507
13774
  RevokeSecurityGroupIngressCommand,
13775
+ AuthorizeSecurityGroupEgressCommand,
13776
+ RevokeSecurityGroupEgressCommand,
13508
13777
  CreateTagsCommand,
13509
13778
  DescribeSubnetsCommand as DescribeSubnetsCommand2,
13510
13779
  DescribeSecurityGroupsCommand as DescribeSecurityGroupsCommand2,
@@ -13554,7 +13823,14 @@ var EC2Provider = class {
13554
13823
  ["AWS::EC2::SubnetRouteTableAssociation", /* @__PURE__ */ new Set(["SubnetId", "RouteTableId"])],
13555
13824
  [
13556
13825
  "AWS::EC2::SecurityGroup",
13557
- /* @__PURE__ */ new Set(["GroupDescription", "GroupName", "VpcId", "SecurityGroupIngress", "Tags"])
13826
+ /* @__PURE__ */ new Set([
13827
+ "GroupDescription",
13828
+ "GroupName",
13829
+ "VpcId",
13830
+ "SecurityGroupIngress",
13831
+ "SecurityGroupEgress",
13832
+ "Tags"
13833
+ ])
13558
13834
  ],
13559
13835
  [
13560
13836
  "AWS::EC2::SecurityGroupIngress",
@@ -13565,7 +13841,8 @@ var EC2Provider = class {
13565
13841
  "ToPort",
13566
13842
  "CidrIp",
13567
13843
  "Description",
13568
- "SourceSecurityGroupId"
13844
+ "SourceSecurityGroupId",
13845
+ "SourceSecurityGroupOwnerId"
13569
13846
  ])
13570
13847
  ],
13571
13848
  [
@@ -13664,7 +13941,13 @@ var EC2Provider = class {
13664
13941
  case "AWS::EC2::SubnetRouteTableAssociation":
13665
13942
  return this.updateSubnetRouteTableAssociation(logicalId, physicalId);
13666
13943
  case "AWS::EC2::SecurityGroup":
13667
- return this.updateSecurityGroup(logicalId, physicalId, resourceType, properties);
13944
+ return this.updateSecurityGroup(
13945
+ logicalId,
13946
+ physicalId,
13947
+ resourceType,
13948
+ properties,
13949
+ previousProperties
13950
+ );
13668
13951
  case "AWS::EC2::SecurityGroupIngress":
13669
13952
  return this.updateSecurityGroupIngress(
13670
13953
  logicalId,
@@ -14389,6 +14672,34 @@ var EC2Provider = class {
14389
14672
  );
14390
14673
  }
14391
14674
  }
14675
+ const egressRules = properties["SecurityGroupEgress"];
14676
+ if (egressRules && Array.isArray(egressRules)) {
14677
+ try {
14678
+ await this.ec2Client.send(
14679
+ new RevokeSecurityGroupEgressCommand({
14680
+ GroupId: groupId,
14681
+ IpPermissions: [
14682
+ {
14683
+ IpProtocol: "-1",
14684
+ IpRanges: [{ CidrIp: "0.0.0.0/0" }]
14685
+ }
14686
+ ]
14687
+ })
14688
+ );
14689
+ } catch (error) {
14690
+ if (!this.isNotFoundError(error)) {
14691
+ throw error;
14692
+ }
14693
+ }
14694
+ for (const rule of egressRules) {
14695
+ await this.ec2Client.send(
14696
+ new AuthorizeSecurityGroupEgressCommand({
14697
+ GroupId: groupId,
14698
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
14699
+ })
14700
+ );
14701
+ }
14702
+ }
14392
14703
  this.logger.debug(`Successfully created SecurityGroup ${logicalId}: ${groupId}`);
14393
14704
  return {
14394
14705
  physicalId: groupId,
@@ -14408,10 +14719,22 @@ var EC2Provider = class {
14408
14719
  );
14409
14720
  }
14410
14721
  }
14411
- async updateSecurityGroup(logicalId, physicalId, resourceType, properties) {
14722
+ async updateSecurityGroup(logicalId, physicalId, resourceType, properties, previousProperties) {
14412
14723
  this.logger.debug(`Updating SecurityGroup ${logicalId}: ${physicalId}`);
14413
14724
  try {
14414
14725
  await this.applyTags(physicalId, properties, logicalId);
14726
+ await this.applySecurityGroupRuleDiff(
14727
+ physicalId,
14728
+ previousProperties["SecurityGroupIngress"] ?? [],
14729
+ properties["SecurityGroupIngress"] ?? [],
14730
+ "ingress"
14731
+ );
14732
+ await this.applySecurityGroupRuleDiff(
14733
+ physicalId,
14734
+ previousProperties["SecurityGroupEgress"] ?? [],
14735
+ properties["SecurityGroupEgress"] ?? [],
14736
+ "egress"
14737
+ );
14415
14738
  this.logger.debug(`Successfully updated SecurityGroup ${logicalId}`);
14416
14739
  return {
14417
14740
  physicalId,
@@ -14769,9 +15092,13 @@ var EC2Provider = class {
14769
15092
  }
14770
15093
  // ─── Helpers ──────────────────────────────────────────────────────
14771
15094
  /**
14772
- * Build an IpPermission object from CloudFormation-style properties
15095
+ * Build an IpPermission object from CloudFormation-style properties.
15096
+ *
15097
+ * The EC2 IpPermission shape is identical for ingress and egress; only the
15098
+ * CFn property names that point to the "other" security group differ
15099
+ * (SourceSecurityGroupId vs DestinationSecurityGroupId).
14773
15100
  */
14774
- buildIpPermission(properties) {
15101
+ buildIpPermission(properties, direction = "ingress") {
14775
15102
  const ipProtocol = properties["IpProtocol"] ?? "-1";
14776
15103
  const fromPort = properties["FromPort"];
14777
15104
  const toPort = properties["ToPort"];
@@ -14781,6 +15108,7 @@ var EC2Provider = class {
14781
15108
  if (toPort !== void 0)
14782
15109
  permission.ToPort = toPort;
14783
15110
  const cidrIp = properties["CidrIp"];
15111
+ const cidrIpv6 = properties["CidrIpv6"];
14784
15112
  const description = properties["Description"];
14785
15113
  if (cidrIp) {
14786
15114
  const ipRange = { CidrIp: cidrIp };
@@ -14788,17 +15116,124 @@ var EC2Provider = class {
14788
15116
  ipRange.Description = description;
14789
15117
  permission.IpRanges = [ipRange];
14790
15118
  }
14791
- const sourceSecurityGroupId = properties["SourceSecurityGroupId"];
14792
- if (sourceSecurityGroupId) {
15119
+ if (cidrIpv6) {
15120
+ const ipv6Range = { CidrIpv6: cidrIpv6 };
15121
+ if (description)
15122
+ ipv6Range.Description = description;
15123
+ permission.Ipv6Ranges = [ipv6Range];
15124
+ }
15125
+ const peerGroupId = direction === "egress" ? properties["DestinationSecurityGroupId"] : properties["SourceSecurityGroupId"];
15126
+ if (peerGroupId) {
14793
15127
  const groupPair = {
14794
- GroupId: sourceSecurityGroupId
15128
+ GroupId: peerGroupId
14795
15129
  };
15130
+ if (direction === "ingress") {
15131
+ const peerOwnerId = properties["SourceSecurityGroupOwnerId"];
15132
+ if (peerOwnerId)
15133
+ groupPair.UserId = peerOwnerId;
15134
+ }
14796
15135
  if (description)
14797
15136
  groupPair.Description = description;
14798
15137
  permission.UserIdGroupPairs = [groupPair];
14799
15138
  }
15139
+ const prefixListId = direction === "egress" ? properties["DestinationPrefixListId"] : properties["SourcePrefixListId"];
15140
+ if (prefixListId) {
15141
+ const prefixEntry = {
15142
+ PrefixListId: prefixListId
15143
+ };
15144
+ if (description)
15145
+ prefixEntry.Description = description;
15146
+ permission.PrefixListIds = [prefixEntry];
15147
+ }
14800
15148
  return permission;
14801
15149
  }
15150
+ /**
15151
+ * Compute the diff between two sets of SecurityGroup rule definitions
15152
+ * (ingress or egress) and apply the resulting authorize/revoke calls.
15153
+ *
15154
+ * Rules are identified by a deterministic key derived from their full
15155
+ * shape — protocol, ports, CIDR, peer group, prefix list, description —
15156
+ * so updating any of those fields counts as a replacement (revoke + authorize).
15157
+ */
15158
+ async applySecurityGroupRuleDiff(groupId, previousRules, nextRules, direction) {
15159
+ const ruleKey = (rule) => {
15160
+ const peerKey = direction === "egress" ? rule["DestinationSecurityGroupId"] : rule["SourceSecurityGroupId"];
15161
+ const prefixKey = direction === "egress" ? rule["DestinationPrefixListId"] : rule["SourcePrefixListId"];
15162
+ const peerOwner = direction === "ingress" ? rule["SourceSecurityGroupOwnerId"] : void 0;
15163
+ return JSON.stringify({
15164
+ p: rule["IpProtocol"] ?? "-1",
15165
+ f: rule["FromPort"] ?? null,
15166
+ t: rule["ToPort"] ?? null,
15167
+ c4: rule["CidrIp"] ?? null,
15168
+ c6: rule["CidrIpv6"] ?? null,
15169
+ peer: peerKey ?? null,
15170
+ peerOwner: peerOwner ?? null,
15171
+ pl: prefixKey ?? null,
15172
+ d: rule["Description"] ?? null
15173
+ });
15174
+ };
15175
+ const prevByKey = /* @__PURE__ */ new Map();
15176
+ for (const rule of previousRules)
15177
+ prevByKey.set(ruleKey(rule), rule);
15178
+ const nextByKey = /* @__PURE__ */ new Map();
15179
+ for (const rule of nextRules)
15180
+ nextByKey.set(ruleKey(rule), rule);
15181
+ const toRevoke = [];
15182
+ for (const [key, rule] of prevByKey) {
15183
+ if (!nextByKey.has(key))
15184
+ toRevoke.push(rule);
15185
+ }
15186
+ const toAuthorize = [];
15187
+ for (const [key, rule] of nextByKey) {
15188
+ if (!prevByKey.has(key))
15189
+ toAuthorize.push(rule);
15190
+ }
15191
+ for (const rule of toRevoke) {
15192
+ try {
15193
+ if (direction === "egress") {
15194
+ await this.ec2Client.send(
15195
+ new RevokeSecurityGroupEgressCommand({
15196
+ GroupId: groupId,
15197
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
15198
+ })
15199
+ );
15200
+ } else {
15201
+ await this.ec2Client.send(
15202
+ new RevokeSecurityGroupIngressCommand({
15203
+ GroupId: groupId,
15204
+ IpPermissions: [this.buildIpPermission(rule, "ingress")]
15205
+ })
15206
+ );
15207
+ }
15208
+ } catch (error) {
15209
+ if (!this.isNotFoundError(error))
15210
+ throw error;
15211
+ }
15212
+ }
15213
+ for (const rule of toAuthorize) {
15214
+ try {
15215
+ if (direction === "egress") {
15216
+ await this.ec2Client.send(
15217
+ new AuthorizeSecurityGroupEgressCommand({
15218
+ GroupId: groupId,
15219
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
15220
+ })
15221
+ );
15222
+ } else {
15223
+ await this.ec2Client.send(
15224
+ new AuthorizeSecurityGroupIngressCommand({
15225
+ GroupId: groupId,
15226
+ IpPermissions: [this.buildIpPermission(rule, "ingress")]
15227
+ })
15228
+ );
15229
+ }
15230
+ } catch (error) {
15231
+ if (!(error instanceof Error && error.message.includes("already exists"))) {
15232
+ throw error;
15233
+ }
15234
+ }
15235
+ }
15236
+ }
14802
15237
  // ─── AWS::EC2::NetworkAcl ────────────────────────────────────────
14803
15238
  async createNetworkAcl(logicalId, resourceType, properties) {
14804
15239
  this.logger.debug(`Creating NetworkAcl ${logicalId}`);
@@ -16719,7 +17154,7 @@ var CloudFrontDistributionProvider = class {
16719
17154
  this.logger.debug(`Created CloudFront Distribution: ${distributionId} (${domainName})`);
16720
17155
  if (process.env["CDKD_NO_WAIT"] !== "true") {
16721
17156
  this.logger.debug(`Waiting for Distribution ${distributionId} to reach Deployed status...`);
16722
- await this.waitForDistributionDeployed(distributionId);
17157
+ await this.waitForDistributionStable(distributionId);
16723
17158
  }
16724
17159
  return {
16725
17160
  physicalId: distributionId,
@@ -16829,7 +17264,7 @@ var CloudFrontDistributionProvider = class {
16829
17264
  })
16830
17265
  );
16831
17266
  etag = updateResponse.ETag;
16832
- await this.waitForDistributionDeployed(physicalId);
17267
+ await this.waitForDistributionStable(physicalId, false);
16833
17268
  const refreshResponse = await this.cloudFrontClient.send(
16834
17269
  new GetDistributionConfigCommand({ Id: physicalId })
16835
17270
  );
@@ -16873,10 +17308,16 @@ var CloudFrontDistributionProvider = class {
16873
17308
  throw new Error(`Unsupported attribute: ${attributeName} for AWS::CloudFront::Distribution`);
16874
17309
  }
16875
17310
  /**
16876
- * Wait for a distribution to reach "Deployed" status.
17311
+ * Wait for a distribution to reach a stable state.
17312
+ *
17313
+ * "Stable" means Status === 'Deployed'. When `expectedEnabled` is provided,
17314
+ * we additionally require DistributionConfig.Enabled === expectedEnabled —
17315
+ * this guards against CloudFront's eventually-consistent reads that can
17316
+ * briefly return the pre-update snapshot after UpdateDistribution returns.
17317
+ *
16877
17318
  * Uses exponential backoff polling.
16878
17319
  */
16879
- async waitForDistributionDeployed(distributionId) {
17320
+ async waitForDistributionStable(distributionId, expectedEnabled) {
16880
17321
  const maxAttempts = 60;
16881
17322
  let delay = 5e3;
16882
17323
  const maxDelay = 3e4;
@@ -16897,12 +17338,16 @@ var CloudFrontDistributionProvider = class {
16897
17338
  new GetDistributionCommand({ Id: distributionId })
16898
17339
  );
16899
17340
  const status = response.Distribution?.Status;
16900
- if (status === "Deployed") {
16901
- this.logger.debug(`Distribution ${distributionId} is now Deployed`);
17341
+ const enabled = response.Distribution?.DistributionConfig?.Enabled;
17342
+ const enabledMatches = expectedEnabled === void 0 || enabled === expectedEnabled;
17343
+ if (status === "Deployed" && enabledMatches) {
17344
+ this.logger.debug(
17345
+ `Distribution ${distributionId} is stable (Status=Deployed, Enabled=${enabled})`
17346
+ );
16902
17347
  return;
16903
17348
  }
16904
17349
  this.logger.debug(
16905
- `Distribution ${distributionId} status: ${status} (attempt ${attempt}/${maxAttempts})`
17350
+ `Distribution ${distributionId} status: ${status}, enabled: ${enabled}` + (expectedEnabled === void 0 ? "" : ` (waiting for Enabled=${expectedEnabled})`) + ` (attempt ${attempt}/${maxAttempts})`
16906
17351
  );
16907
17352
  const sleepEnd = Date.now() + delay;
16908
17353
  while (Date.now() < sleepEnd && !interrupted) {
@@ -16911,7 +17356,7 @@ var CloudFrontDistributionProvider = class {
16911
17356
  delay = Math.min(delay * 1.5, maxDelay);
16912
17357
  }
16913
17358
  this.logger.debug(
16914
- `Distribution ${distributionId} did not reach Deployed status within timeout, proceeding with deletion attempt`
17359
+ `Distribution ${distributionId} did not reach stable state within timeout, proceeding with next step`
16915
17360
  );
16916
17361
  } finally {
16917
17362
  process.removeListener("SIGINT", sigintHandler);
@@ -24137,7 +24582,7 @@ import {
24137
24582
  DeleteObjectsCommand as DeleteObjectsCommand2
24138
24583
  } from "@aws-sdk/client-s3";
24139
24584
  import { GetCallerIdentityCommand as GetCallerIdentityCommand7 } from "@aws-sdk/client-sts";
24140
- import { EC2Client as EC2Client7, DescribeAvailabilityZonesCommand as DescribeAvailabilityZonesCommand3 } from "@aws-sdk/client-ec2";
24585
+ import { EC2Client as EC2Client8, DescribeAvailabilityZonesCommand as DescribeAvailabilityZonesCommand3 } from "@aws-sdk/client-ec2";
24141
24586
  init_aws_clients();
24142
24587
  var S3DirectoryBucketProvider = class {
24143
24588
  s3Client;
@@ -24155,7 +24600,7 @@ var S3DirectoryBucketProvider = class {
24155
24600
  }
24156
24601
  getEc2Client() {
24157
24602
  if (!this.ec2Client) {
24158
- this.ec2Client = new EC2Client7(this.providerRegion ? { region: this.providerRegion } : {});
24603
+ this.ec2Client = new EC2Client8(this.providerRegion ? { region: this.providerRegion } : {});
24159
24604
  }
24160
24605
  return this.ec2Client;
24161
24606
  }
@@ -25142,6 +25587,44 @@ var DagExecutor = class {
25142
25587
  }
25143
25588
  };
25144
25589
 
25590
+ // src/analyzer/implicit-delete-deps.ts
25591
+ var IMPLICIT_DELETE_DEPENDENCIES = {
25592
+ // IGW must be deleted AFTER VPCGatewayAttachment
25593
+ "AWS::EC2::InternetGateway": ["AWS::EC2::VPCGatewayAttachment"],
25594
+ // EventBus must be deleted AFTER Rules on that bus
25595
+ "AWS::Events::EventBus": ["AWS::Events::Rule"],
25596
+ // Athena workgroup must be deleted AFTER its named queries
25597
+ "AWS::Athena::WorkGroup": ["AWS::Athena::NamedQuery"],
25598
+ // CloudFront managed-policy-style resources must be deleted AFTER
25599
+ // any Distribution that references them
25600
+ "AWS::CloudFront::ResponseHeadersPolicy": ["AWS::CloudFront::Distribution"],
25601
+ "AWS::CloudFront::CachePolicy": ["AWS::CloudFront::Distribution"],
25602
+ "AWS::CloudFront::OriginAccessControl": ["AWS::CloudFront::Distribution"],
25603
+ // VPC must be deleted AFTER all VPC-dependent resources
25604
+ "AWS::EC2::VPC": [
25605
+ "AWS::EC2::Subnet",
25606
+ "AWS::EC2::SecurityGroup",
25607
+ "AWS::EC2::InternetGateway",
25608
+ "AWS::EC2::EgressOnlyInternetGateway",
25609
+ "AWS::EC2::VPCGatewayAttachment",
25610
+ "AWS::EC2::RouteTable"
25611
+ ],
25612
+ // Subnet must be deleted AFTER any Lambda that may still hold an ENI
25613
+ // in it. Lambda DELETE returns immediately but the ENI is detached
25614
+ // asynchronously by AWS, so deleting the Subnet first races the detach
25615
+ // and yields "DependencyViolation".
25616
+ "AWS::EC2::Subnet": ["AWS::EC2::SubnetRouteTableAssociation", "AWS::Lambda::Function"],
25617
+ // RouteTable must be deleted AFTER Route and Association
25618
+ "AWS::EC2::RouteTable": ["AWS::EC2::Route", "AWS::EC2::SubnetRouteTableAssociation"],
25619
+ // SecurityGroup must be deleted AFTER any Lambda whose ENI is bound
25620
+ // to it (same ENI-detach race as Subnet above).
25621
+ "AWS::EC2::SecurityGroup": [
25622
+ "AWS::EC2::SecurityGroupIngress",
25623
+ "AWS::EC2::SecurityGroupEgress",
25624
+ "AWS::Lambda::Function"
25625
+ ]
25626
+ };
25627
+
25145
25628
  // src/deployment/deploy-engine.ts
25146
25629
  var InterruptedError = class extends Error {
25147
25630
  constructor() {
@@ -25149,7 +25632,7 @@ var InterruptedError = class extends Error {
25149
25632
  this.name = "InterruptedError";
25150
25633
  }
25151
25634
  };
25152
- var DeployEngine = class _DeployEngine {
25635
+ var DeployEngine = class {
25153
25636
  constructor(stateBackend, lockManager, dagBuilder, diffCalculator, providerRegistry, options = {}, stackRegion) {
25154
25637
  this.stateBackend = stateBackend;
25155
25638
  this.lockManager = lockManager;
@@ -25986,36 +26469,9 @@ var DeployEngine = class _DeployEngine {
25986
26469
  const deps = parser.extractDependencies(resource);
25987
26470
  return deps.size > 0 ? [...deps] : void 0;
25988
26471
  }
25989
- /**
25990
- * Implicit dependency map for correct deletion order.
25991
- *
25992
- * Key = resource type that must be deleted AFTER all value types are deleted.
25993
- * Value = resource types that must be deleted BEFORE the key type.
25994
- *
25995
- * Example: InternetGateway depends on VPCGatewayAttachment being deleted first,
25996
- * because AWS won't let you delete an IGW while it's still attached to a VPC.
25997
- */
25998
- static IMPLICIT_DELETE_DEPENDENCIES = {
25999
- // IGW must be deleted AFTER VPCGatewayAttachment
26000
- "AWS::EC2::InternetGateway": ["AWS::EC2::VPCGatewayAttachment"],
26001
- // EventBus must be deleted AFTER Rules on that bus
26002
- "AWS::Events::EventBus": ["AWS::Events::Rule"],
26003
- // VPC must be deleted AFTER all VPC-dependent resources
26004
- "AWS::EC2::VPC": [
26005
- "AWS::EC2::Subnet",
26006
- "AWS::EC2::SecurityGroup",
26007
- "AWS::EC2::InternetGateway",
26008
- "AWS::EC2::EgressOnlyInternetGateway",
26009
- "AWS::EC2::VPCGatewayAttachment",
26010
- "AWS::EC2::RouteTable"
26011
- ],
26012
- // Subnet must be deleted AFTER RouteTableAssociation
26013
- "AWS::EC2::Subnet": ["AWS::EC2::SubnetRouteTableAssociation"],
26014
- // RouteTable must be deleted AFTER Route and Association
26015
- "AWS::EC2::RouteTable": ["AWS::EC2::Route", "AWS::EC2::SubnetRouteTableAssociation"],
26016
- // SecurityGroup must be deleted AFTER SecurityGroupIngress/Egress
26017
- "AWS::EC2::SecurityGroup": ["AWS::EC2::SecurityGroupIngress", "AWS::EC2::SecurityGroupEgress"]
26018
- };
26472
+ // Type-based implicit deletion ordering rules are defined in
26473
+ // src/analyzer/implicit-delete-deps.ts so the deploy DELETE phase and the
26474
+ // standalone destroy command apply the same rules.
26019
26475
  /**
26020
26476
  * Build a per-resource map of "must be deleted before me" dependencies for
26021
26477
  * the DELETE phase, derived from state-recorded dependencies plus implicit
@@ -26081,7 +26537,7 @@ var DeployEngine = class _DeployEngine {
26081
26537
  const resource = state.resources[id];
26082
26538
  if (!resource)
26083
26539
  continue;
26084
- const mustDeleteAfter = _DeployEngine.IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26540
+ const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26085
26541
  if (!mustDeleteAfter)
26086
26542
  continue;
26087
26543
  for (const depType of mustDeleteAfter) {
@@ -26867,27 +27323,6 @@ Acquiring lock for stack ${stackName}...`);
26867
27323
  }
26868
27324
  };
26869
27325
  }
26870
- const implicitDeleteDeps = {
26871
- "AWS::EC2::InternetGateway": ["AWS::EC2::VPCGatewayAttachment"],
26872
- "AWS::Events::EventBus": ["AWS::Events::Rule"],
26873
- "AWS::Athena::WorkGroup": ["AWS::Athena::NamedQuery"],
26874
- "AWS::CloudFront::ResponseHeadersPolicy": ["AWS::CloudFront::Distribution"],
26875
- "AWS::CloudFront::CachePolicy": ["AWS::CloudFront::Distribution"],
26876
- "AWS::CloudFront::OriginAccessControl": ["AWS::CloudFront::Distribution"],
26877
- "AWS::EC2::VPC": [
26878
- "AWS::EC2::Subnet",
26879
- "AWS::EC2::SecurityGroup",
26880
- "AWS::EC2::InternetGateway",
26881
- "AWS::EC2::VPCGatewayAttachment",
26882
- "AWS::EC2::RouteTable"
26883
- ],
26884
- "AWS::EC2::Subnet": ["AWS::EC2::SubnetRouteTableAssociation"],
26885
- "AWS::EC2::RouteTable": ["AWS::EC2::Route", "AWS::EC2::SubnetRouteTableAssociation"],
26886
- "AWS::EC2::SecurityGroup": [
26887
- "AWS::EC2::SecurityGroupIngress",
26888
- "AWS::EC2::SecurityGroupEgress"
26889
- ]
26890
- };
26891
27326
  const typeToLogicalIds = /* @__PURE__ */ new Map();
26892
27327
  for (const [logicalId, resource] of Object.entries(currentState.resources)) {
26893
27328
  const ids = typeToLogicalIds.get(resource.resourceType) ?? [];
@@ -26895,7 +27330,7 @@ Acquiring lock for stack ${stackName}...`);
26895
27330
  typeToLogicalIds.set(resource.resourceType, ids);
26896
27331
  }
26897
27332
  for (const [logicalId, resource] of Object.entries(currentState.resources)) {
26898
- const mustDeleteAfter = implicitDeleteDeps[resource.resourceType];
27333
+ const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26899
27334
  if (!mustDeleteAfter)
26900
27335
  continue;
26901
27336
  for (const depType of mustDeleteAfter) {
@@ -27119,7 +27554,7 @@ function reorderArgs(argv) {
27119
27554
  }
27120
27555
  async function main() {
27121
27556
  const program = new Command8();
27122
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.2.0");
27557
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.3.0");
27123
27558
  program.addCommand(createBootstrapCommand());
27124
27559
  program.addCommand(createSynthCommand());
27125
27560
  program.addCommand(createDeployCommand());