@go-to-k/cdkd 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,14 @@ import {
10897
10987
  GetFunctionCommand,
10898
10988
  ResourceNotFoundException
10899
10989
  } from "@aws-sdk/client-lambda";
10990
+ import {
10991
+ DescribeNetworkInterfacesCommand,
10992
+ DeleteNetworkInterfaceCommand
10993
+ } from "@aws-sdk/client-ec2";
10900
10994
  init_aws_clients();
10901
10995
  var LambdaFunctionProvider = class {
10902
10996
  lambdaClient;
10997
+ ec2Client;
10903
10998
  logger = getLogger().child("LambdaFunctionProvider");
10904
10999
  handledProperties = /* @__PURE__ */ new Map([
10905
11000
  [
@@ -10919,13 +11014,22 @@ var LambdaFunctionProvider = class {
10919
11014
  "Architectures",
10920
11015
  "PackageType",
10921
11016
  "TracingConfig",
10922
- "EphemeralStorage"
11017
+ "EphemeralStorage",
11018
+ "VpcConfig"
10923
11019
  ])
10924
11020
  ]
10925
11021
  ]);
11022
+ // ENI detach polling configuration (overridable for tests).
11023
+ // Lambda VPC ENI detach is async and can take 20-40 minutes in the worst case;
11024
+ // we poll up to 10 minutes and then warn-and-continue, since downstream Subnet/SG
11025
+ // deletion has its own retry logic that handles a small remaining window.
11026
+ eniWaitTimeoutMs = 10 * 60 * 1e3;
11027
+ eniWaitInitialDelayMs = 1e4;
11028
+ eniWaitMaxDelayMs = 3e4;
10926
11029
  constructor() {
10927
11030
  const awsClients = getAwsClients();
10928
11031
  this.lambdaClient = awsClients.lambda;
11032
+ this.ec2Client = awsClients.ec2;
10929
11033
  }
10930
11034
  /**
10931
11035
  * Create a Lambda function
@@ -10973,6 +11077,7 @@ var LambdaFunctionProvider = class {
10973
11077
  PackageType: properties["PackageType"],
10974
11078
  TracingConfig: properties["TracingConfig"],
10975
11079
  EphemeralStorage: properties["EphemeralStorage"],
11080
+ VpcConfig: this.buildVpcConfig(properties["VpcConfig"]),
10976
11081
  Tags: tags
10977
11082
  };
10978
11083
  const response = await this.lambdaClient.send(new CreateFunctionCommand(createParams));
@@ -11011,7 +11116,8 @@ var LambdaFunctionProvider = class {
11011
11116
  "Environment",
11012
11117
  "Layers",
11013
11118
  "TracingConfig",
11014
- "EphemeralStorage"
11119
+ "EphemeralStorage",
11120
+ "VpcConfig"
11015
11121
  ];
11016
11122
  let hasConfigChanges = false;
11017
11123
  for (const field of configFields) {
@@ -11032,7 +11138,11 @@ var LambdaFunctionProvider = class {
11032
11138
  Environment: properties["Environment"],
11033
11139
  Layers: properties["Layers"],
11034
11140
  TracingConfig: properties["TracingConfig"],
11035
- EphemeralStorage: properties["EphemeralStorage"]
11141
+ EphemeralStorage: properties["EphemeralStorage"],
11142
+ VpcConfig: this.buildVpcConfigForUpdate(
11143
+ properties["VpcConfig"],
11144
+ previousProperties["VpcConfig"]
11145
+ )
11036
11146
  };
11037
11147
  await this.lambdaClient.send(new UpdateFunctionConfigurationCommand(configParams));
11038
11148
  this.logger.debug(`Updated configuration for Lambda function ${physicalId}`);
@@ -11076,9 +11186,37 @@ var LambdaFunctionProvider = class {
11076
11186
  }
11077
11187
  /**
11078
11188
  * Delete a Lambda function
11189
+ *
11190
+ * For VPC-enabled Lambda functions, AWS detaches the hyperplane ENIs
11191
+ * asynchronously after DeleteFunction returns. If we let downstream
11192
+ * resource deletion (Subnet / SecurityGroup) proceed immediately, those
11193
+ * deletions fail with "has dependencies" / "has a dependent object".
11194
+ *
11195
+ * To smooth this out, when properties carry a VpcConfig with subnets or
11196
+ * security groups, we poll DescribeNetworkInterfaces for the function's
11197
+ * managed ENIs and only return once they are gone (or the timeout elapses).
11079
11198
  */
11080
- async delete(logicalId, physicalId, resourceType, _properties) {
11199
+ async delete(logicalId, physicalId, resourceType, properties) {
11081
11200
  this.logger.debug(`Deleting Lambda function ${logicalId}: ${physicalId}`);
11201
+ const hasVpcConfig = this.hasVpcConfig(properties?.["VpcConfig"]);
11202
+ if (hasVpcConfig) {
11203
+ try {
11204
+ await this.lambdaClient.send(
11205
+ new UpdateFunctionConfigurationCommand({
11206
+ FunctionName: physicalId,
11207
+ VpcConfig: { SubnetIds: [], SecurityGroupIds: [] }
11208
+ })
11209
+ );
11210
+ this.logger.debug(`Detached VPC config from Lambda ${physicalId} before deletion`);
11211
+ } catch (error) {
11212
+ if (error instanceof ResourceNotFoundException) {
11213
+ return;
11214
+ }
11215
+ this.logger.warn(
11216
+ `Pre-delete VPC detach failed for ${physicalId}: ${error instanceof Error ? error.message : String(error)} \u2014 continuing with delete`
11217
+ );
11218
+ }
11219
+ }
11082
11220
  try {
11083
11221
  await this.lambdaClient.send(new DeleteFunctionCommand({ FunctionName: physicalId }));
11084
11222
  this.logger.debug(`Successfully deleted Lambda function ${logicalId}`);
@@ -11096,6 +11234,183 @@ var LambdaFunctionProvider = class {
11096
11234
  cause
11097
11235
  );
11098
11236
  }
11237
+ if (hasVpcConfig) {
11238
+ await this.cleanupLambdaEnis(physicalId);
11239
+ }
11240
+ }
11241
+ /**
11242
+ * Build Lambda VpcConfig parameter from CDK properties.
11243
+ *
11244
+ * Returns undefined when VpcConfig is unset, so the SDK leaves the function
11245
+ * outside any VPC. Returns an empty config (no subnets, no SGs) when caller
11246
+ * explicitly clears it on update — that detaches the function from its VPC.
11247
+ */
11248
+ buildVpcConfig(raw) {
11249
+ if (raw === void 0 || raw === null) {
11250
+ return void 0;
11251
+ }
11252
+ if (typeof raw !== "object") {
11253
+ return void 0;
11254
+ }
11255
+ const vpc = raw;
11256
+ const result = {};
11257
+ if (Array.isArray(vpc["SubnetIds"])) {
11258
+ result.SubnetIds = vpc["SubnetIds"];
11259
+ }
11260
+ if (Array.isArray(vpc["SecurityGroupIds"])) {
11261
+ result.SecurityGroupIds = vpc["SecurityGroupIds"];
11262
+ }
11263
+ if (typeof vpc["Ipv6AllowedForDualStack"] === "boolean") {
11264
+ result.Ipv6AllowedForDualStack = vpc["Ipv6AllowedForDualStack"];
11265
+ }
11266
+ return result;
11267
+ }
11268
+ /**
11269
+ * Build VpcConfig for an update call, accounting for VPC detach.
11270
+ *
11271
+ * UpdateFunctionConfiguration treats an absent VpcConfig as "no change",
11272
+ * so omitting it cannot move a function out of its existing VPC. To
11273
+ * detach we must explicitly send empty SubnetIds / SecurityGroupIds.
11274
+ */
11275
+ buildVpcConfigForUpdate(newRaw, previousRaw) {
11276
+ const next = this.buildVpcConfig(newRaw);
11277
+ if (next) {
11278
+ return next;
11279
+ }
11280
+ if (this.hasVpcConfig(previousRaw)) {
11281
+ return { SubnetIds: [], SecurityGroupIds: [] };
11282
+ }
11283
+ return void 0;
11284
+ }
11285
+ /**
11286
+ * Determine whether the function actually attaches to a VPC, i.e. has at
11287
+ * least one Subnet ID. A bare VpcConfig with empty arrays does not create
11288
+ * any ENIs, so we skip the wait in that case.
11289
+ */
11290
+ hasVpcConfig(raw) {
11291
+ if (raw === void 0 || raw === null || typeof raw !== "object") {
11292
+ return false;
11293
+ }
11294
+ const vpc = raw;
11295
+ const subnets = vpc["SubnetIds"];
11296
+ return Array.isArray(subnets) && subnets.length > 0;
11297
+ }
11298
+ /**
11299
+ * Clean up Lambda-managed ENIs for the given function: list, then attempt
11300
+ * DeleteNetworkInterface on each. Repeat until no matching ENIs remain
11301
+ * or the configured timeout elapses.
11302
+ *
11303
+ * Why direct delete (not just wait): an `available` ENI still counts as a
11304
+ * Subnet / SecurityGroup dependency, so DeleteSubnet / DeleteSecurityGroup
11305
+ * fail until the ENI itself is gone. AWS's eventual cleanup of unused
11306
+ * Lambda hyperplane ENIs can take well over an hour, which is far longer
11307
+ * than any reasonable destroy budget. Calling DeleteNetworkInterface
11308
+ * ourselves (best-effort) clears `available` ENIs in seconds.
11309
+ *
11310
+ * In-use ENIs (e.g. immediately after the pre-delete VPC detach) cannot
11311
+ * be deleted yet — we swallow that error and retry on the next iteration
11312
+ * once they transition to `available`.
11313
+ *
11314
+ * Lambda VPC ENI Descriptions follow the pattern
11315
+ * "AWS Lambda VPC ENI-<functionName>"
11316
+ * (and historically "AWS Lambda VPC ENI-<functionName>-<uuid>"). We
11317
+ * narrow the query with a `requester-id` filter and then match the
11318
+ * function name as a hyphen-bounded token to avoid false positives like
11319
+ * "myfn" matching for function "fn".
11320
+ *
11321
+ * Polling: starts at eniWaitInitialDelayMs (10s), exponential backoff up
11322
+ * to eniWaitMaxDelayMs (30s), bounded by eniWaitTimeoutMs (10min).
11323
+ * Timeout is a soft warning — downstream Subnet/SG deletion has its own
11324
+ * retries.
11325
+ */
11326
+ async cleanupLambdaEnis(functionName) {
11327
+ const start = Date.now();
11328
+ let delay = this.eniWaitInitialDelayMs;
11329
+ let attempt = 0;
11330
+ this.logger.debug(
11331
+ `Cleaning up Lambda VPC ENIs for function ${functionName} (timeout ${this.eniWaitTimeoutMs}ms)`
11332
+ );
11333
+ const descriptionNeedle = `AWS Lambda VPC ENI`;
11334
+ const functionNamePattern = new RegExp(`(^|-)${escapeRegExp(functionName)}(-|$)`);
11335
+ for (; ; ) {
11336
+ attempt++;
11337
+ let enis = [];
11338
+ let listFailed = false;
11339
+ try {
11340
+ enis = await this.listLambdaEnis(descriptionNeedle, functionNamePattern);
11341
+ } catch (error) {
11342
+ this.logger.warn(
11343
+ `DescribeNetworkInterfaces failed while cleaning up Lambda ENIs of ${functionName}: ${error instanceof Error ? error.message : String(error)}`
11344
+ );
11345
+ listFailed = true;
11346
+ }
11347
+ if (!listFailed && enis.length === 0) {
11348
+ this.logger.debug(
11349
+ `Lambda ENIs for ${functionName} fully cleaned up after ${attempt} attempt(s) / ${Date.now() - start}ms`
11350
+ );
11351
+ return;
11352
+ }
11353
+ if (enis.length > 0) {
11354
+ await Promise.all(
11355
+ enis.map(async (eni) => {
11356
+ try {
11357
+ await this.ec2Client.send(
11358
+ new DeleteNetworkInterfaceCommand({ NetworkInterfaceId: eni.id })
11359
+ );
11360
+ this.logger.debug(`Deleted Lambda ENI ${eni.id} for ${functionName}`);
11361
+ } catch (error) {
11362
+ this.logger.debug(
11363
+ `ENI ${eni.id} (status=${eni.status}) not yet deletable: ${error instanceof Error ? error.message : String(error)}`
11364
+ );
11365
+ }
11366
+ })
11367
+ );
11368
+ }
11369
+ const elapsed = Date.now() - start;
11370
+ if (elapsed >= this.eniWaitTimeoutMs) {
11371
+ this.logger.warn(
11372
+ `Timeout (${this.eniWaitTimeoutMs}ms) cleaning up Lambda VPC ENIs of ${functionName} (${enis.length} remaining). Continuing \u2014 downstream Subnet/SG deletion will retry as needed.`
11373
+ );
11374
+ return;
11375
+ }
11376
+ const remaining = this.eniWaitTimeoutMs - elapsed;
11377
+ const sleepMs = Math.min(delay, remaining);
11378
+ await this.sleep(sleepMs);
11379
+ delay = Math.min(delay * 2, this.eniWaitMaxDelayMs);
11380
+ }
11381
+ }
11382
+ /**
11383
+ * List Lambda-managed ENIs for the given function, paginating through
11384
+ * DescribeNetworkInterfaces and filtering on Description substring.
11385
+ *
11386
+ * Server-side filter (`description`) does not support wildcards on this
11387
+ * API, so we narrow with `requester-id` + match Description client-side.
11388
+ */
11389
+ async listLambdaEnis(descriptionNeedle, functionNamePattern) {
11390
+ const enis = [];
11391
+ let nextToken;
11392
+ do {
11393
+ const resp = await this.ec2Client.send(
11394
+ new DescribeNetworkInterfacesCommand({
11395
+ Filters: [
11396
+ // Lambda hyperplane ENIs are owned by the Lambda service principal.
11397
+ { Name: "requester-id", Values: ["*:awslambda_*"] }
11398
+ ],
11399
+ NextToken: nextToken
11400
+ })
11401
+ );
11402
+ for (const ni of resp.NetworkInterfaces ?? []) {
11403
+ const desc = ni.Description ?? "";
11404
+ if (ni.NetworkInterfaceId && desc.includes(descriptionNeedle) && functionNamePattern.test(desc)) {
11405
+ enis.push({ id: ni.NetworkInterfaceId, status: ni.Status ?? "unknown" });
11406
+ }
11407
+ }
11408
+ nextToken = resp.NextToken;
11409
+ } while (nextToken);
11410
+ return enis;
11411
+ }
11412
+ sleep(ms) {
11413
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
11099
11414
  }
11100
11415
  /**
11101
11416
  * Build Lambda Code parameter from CDK properties
@@ -11182,6 +11497,9 @@ var LambdaFunctionProvider = class {
11182
11497
  return (crc ^ 4294967295) >>> 0;
11183
11498
  }
11184
11499
  };
11500
+ function escapeRegExp(input) {
11501
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11502
+ }
11185
11503
 
11186
11504
  // src/provisioning/providers/lambda-permission-provider.ts
11187
11505
  import {
@@ -13505,6 +13823,8 @@ import {
13505
13823
  DeleteSecurityGroupCommand,
13506
13824
  AuthorizeSecurityGroupIngressCommand,
13507
13825
  RevokeSecurityGroupIngressCommand,
13826
+ AuthorizeSecurityGroupEgressCommand,
13827
+ RevokeSecurityGroupEgressCommand,
13508
13828
  CreateTagsCommand,
13509
13829
  DescribeSubnetsCommand as DescribeSubnetsCommand2,
13510
13830
  DescribeSecurityGroupsCommand as DescribeSecurityGroupsCommand2,
@@ -13554,7 +13874,14 @@ var EC2Provider = class {
13554
13874
  ["AWS::EC2::SubnetRouteTableAssociation", /* @__PURE__ */ new Set(["SubnetId", "RouteTableId"])],
13555
13875
  [
13556
13876
  "AWS::EC2::SecurityGroup",
13557
- /* @__PURE__ */ new Set(["GroupDescription", "GroupName", "VpcId", "SecurityGroupIngress", "Tags"])
13877
+ /* @__PURE__ */ new Set([
13878
+ "GroupDescription",
13879
+ "GroupName",
13880
+ "VpcId",
13881
+ "SecurityGroupIngress",
13882
+ "SecurityGroupEgress",
13883
+ "Tags"
13884
+ ])
13558
13885
  ],
13559
13886
  [
13560
13887
  "AWS::EC2::SecurityGroupIngress",
@@ -13565,7 +13892,8 @@ var EC2Provider = class {
13565
13892
  "ToPort",
13566
13893
  "CidrIp",
13567
13894
  "Description",
13568
- "SourceSecurityGroupId"
13895
+ "SourceSecurityGroupId",
13896
+ "SourceSecurityGroupOwnerId"
13569
13897
  ])
13570
13898
  ],
13571
13899
  [
@@ -13664,7 +13992,13 @@ var EC2Provider = class {
13664
13992
  case "AWS::EC2::SubnetRouteTableAssociation":
13665
13993
  return this.updateSubnetRouteTableAssociation(logicalId, physicalId);
13666
13994
  case "AWS::EC2::SecurityGroup":
13667
- return this.updateSecurityGroup(logicalId, physicalId, resourceType, properties);
13995
+ return this.updateSecurityGroup(
13996
+ logicalId,
13997
+ physicalId,
13998
+ resourceType,
13999
+ properties,
14000
+ previousProperties
14001
+ );
13668
14002
  case "AWS::EC2::SecurityGroupIngress":
13669
14003
  return this.updateSecurityGroupIngress(
13670
14004
  logicalId,
@@ -14389,6 +14723,34 @@ var EC2Provider = class {
14389
14723
  );
14390
14724
  }
14391
14725
  }
14726
+ const egressRules = properties["SecurityGroupEgress"];
14727
+ if (egressRules && Array.isArray(egressRules)) {
14728
+ try {
14729
+ await this.ec2Client.send(
14730
+ new RevokeSecurityGroupEgressCommand({
14731
+ GroupId: groupId,
14732
+ IpPermissions: [
14733
+ {
14734
+ IpProtocol: "-1",
14735
+ IpRanges: [{ CidrIp: "0.0.0.0/0" }]
14736
+ }
14737
+ ]
14738
+ })
14739
+ );
14740
+ } catch (error) {
14741
+ if (!this.isNotFoundError(error)) {
14742
+ throw error;
14743
+ }
14744
+ }
14745
+ for (const rule of egressRules) {
14746
+ await this.ec2Client.send(
14747
+ new AuthorizeSecurityGroupEgressCommand({
14748
+ GroupId: groupId,
14749
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
14750
+ })
14751
+ );
14752
+ }
14753
+ }
14392
14754
  this.logger.debug(`Successfully created SecurityGroup ${logicalId}: ${groupId}`);
14393
14755
  return {
14394
14756
  physicalId: groupId,
@@ -14408,10 +14770,22 @@ var EC2Provider = class {
14408
14770
  );
14409
14771
  }
14410
14772
  }
14411
- async updateSecurityGroup(logicalId, physicalId, resourceType, properties) {
14773
+ async updateSecurityGroup(logicalId, physicalId, resourceType, properties, previousProperties) {
14412
14774
  this.logger.debug(`Updating SecurityGroup ${logicalId}: ${physicalId}`);
14413
14775
  try {
14414
14776
  await this.applyTags(physicalId, properties, logicalId);
14777
+ await this.applySecurityGroupRuleDiff(
14778
+ physicalId,
14779
+ previousProperties["SecurityGroupIngress"] ?? [],
14780
+ properties["SecurityGroupIngress"] ?? [],
14781
+ "ingress"
14782
+ );
14783
+ await this.applySecurityGroupRuleDiff(
14784
+ physicalId,
14785
+ previousProperties["SecurityGroupEgress"] ?? [],
14786
+ properties["SecurityGroupEgress"] ?? [],
14787
+ "egress"
14788
+ );
14415
14789
  this.logger.debug(`Successfully updated SecurityGroup ${logicalId}`);
14416
14790
  return {
14417
14791
  physicalId,
@@ -14769,9 +15143,13 @@ var EC2Provider = class {
14769
15143
  }
14770
15144
  // ─── Helpers ──────────────────────────────────────────────────────
14771
15145
  /**
14772
- * Build an IpPermission object from CloudFormation-style properties
15146
+ * Build an IpPermission object from CloudFormation-style properties.
15147
+ *
15148
+ * The EC2 IpPermission shape is identical for ingress and egress; only the
15149
+ * CFn property names that point to the "other" security group differ
15150
+ * (SourceSecurityGroupId vs DestinationSecurityGroupId).
14773
15151
  */
14774
- buildIpPermission(properties) {
15152
+ buildIpPermission(properties, direction = "ingress") {
14775
15153
  const ipProtocol = properties["IpProtocol"] ?? "-1";
14776
15154
  const fromPort = properties["FromPort"];
14777
15155
  const toPort = properties["ToPort"];
@@ -14781,6 +15159,7 @@ var EC2Provider = class {
14781
15159
  if (toPort !== void 0)
14782
15160
  permission.ToPort = toPort;
14783
15161
  const cidrIp = properties["CidrIp"];
15162
+ const cidrIpv6 = properties["CidrIpv6"];
14784
15163
  const description = properties["Description"];
14785
15164
  if (cidrIp) {
14786
15165
  const ipRange = { CidrIp: cidrIp };
@@ -14788,17 +15167,124 @@ var EC2Provider = class {
14788
15167
  ipRange.Description = description;
14789
15168
  permission.IpRanges = [ipRange];
14790
15169
  }
14791
- const sourceSecurityGroupId = properties["SourceSecurityGroupId"];
14792
- if (sourceSecurityGroupId) {
15170
+ if (cidrIpv6) {
15171
+ const ipv6Range = { CidrIpv6: cidrIpv6 };
15172
+ if (description)
15173
+ ipv6Range.Description = description;
15174
+ permission.Ipv6Ranges = [ipv6Range];
15175
+ }
15176
+ const peerGroupId = direction === "egress" ? properties["DestinationSecurityGroupId"] : properties["SourceSecurityGroupId"];
15177
+ if (peerGroupId) {
14793
15178
  const groupPair = {
14794
- GroupId: sourceSecurityGroupId
15179
+ GroupId: peerGroupId
14795
15180
  };
15181
+ if (direction === "ingress") {
15182
+ const peerOwnerId = properties["SourceSecurityGroupOwnerId"];
15183
+ if (peerOwnerId)
15184
+ groupPair.UserId = peerOwnerId;
15185
+ }
14796
15186
  if (description)
14797
15187
  groupPair.Description = description;
14798
15188
  permission.UserIdGroupPairs = [groupPair];
14799
15189
  }
15190
+ const prefixListId = direction === "egress" ? properties["DestinationPrefixListId"] : properties["SourcePrefixListId"];
15191
+ if (prefixListId) {
15192
+ const prefixEntry = {
15193
+ PrefixListId: prefixListId
15194
+ };
15195
+ if (description)
15196
+ prefixEntry.Description = description;
15197
+ permission.PrefixListIds = [prefixEntry];
15198
+ }
14800
15199
  return permission;
14801
15200
  }
15201
+ /**
15202
+ * Compute the diff between two sets of SecurityGroup rule definitions
15203
+ * (ingress or egress) and apply the resulting authorize/revoke calls.
15204
+ *
15205
+ * Rules are identified by a deterministic key derived from their full
15206
+ * shape — protocol, ports, CIDR, peer group, prefix list, description —
15207
+ * so updating any of those fields counts as a replacement (revoke + authorize).
15208
+ */
15209
+ async applySecurityGroupRuleDiff(groupId, previousRules, nextRules, direction) {
15210
+ const ruleKey = (rule) => {
15211
+ const peerKey = direction === "egress" ? rule["DestinationSecurityGroupId"] : rule["SourceSecurityGroupId"];
15212
+ const prefixKey = direction === "egress" ? rule["DestinationPrefixListId"] : rule["SourcePrefixListId"];
15213
+ const peerOwner = direction === "ingress" ? rule["SourceSecurityGroupOwnerId"] : void 0;
15214
+ return JSON.stringify({
15215
+ p: rule["IpProtocol"] ?? "-1",
15216
+ f: rule["FromPort"] ?? null,
15217
+ t: rule["ToPort"] ?? null,
15218
+ c4: rule["CidrIp"] ?? null,
15219
+ c6: rule["CidrIpv6"] ?? null,
15220
+ peer: peerKey ?? null,
15221
+ peerOwner: peerOwner ?? null,
15222
+ pl: prefixKey ?? null,
15223
+ d: rule["Description"] ?? null
15224
+ });
15225
+ };
15226
+ const prevByKey = /* @__PURE__ */ new Map();
15227
+ for (const rule of previousRules)
15228
+ prevByKey.set(ruleKey(rule), rule);
15229
+ const nextByKey = /* @__PURE__ */ new Map();
15230
+ for (const rule of nextRules)
15231
+ nextByKey.set(ruleKey(rule), rule);
15232
+ const toRevoke = [];
15233
+ for (const [key, rule] of prevByKey) {
15234
+ if (!nextByKey.has(key))
15235
+ toRevoke.push(rule);
15236
+ }
15237
+ const toAuthorize = [];
15238
+ for (const [key, rule] of nextByKey) {
15239
+ if (!prevByKey.has(key))
15240
+ toAuthorize.push(rule);
15241
+ }
15242
+ for (const rule of toRevoke) {
15243
+ try {
15244
+ if (direction === "egress") {
15245
+ await this.ec2Client.send(
15246
+ new RevokeSecurityGroupEgressCommand({
15247
+ GroupId: groupId,
15248
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
15249
+ })
15250
+ );
15251
+ } else {
15252
+ await this.ec2Client.send(
15253
+ new RevokeSecurityGroupIngressCommand({
15254
+ GroupId: groupId,
15255
+ IpPermissions: [this.buildIpPermission(rule, "ingress")]
15256
+ })
15257
+ );
15258
+ }
15259
+ } catch (error) {
15260
+ if (!this.isNotFoundError(error))
15261
+ throw error;
15262
+ }
15263
+ }
15264
+ for (const rule of toAuthorize) {
15265
+ try {
15266
+ if (direction === "egress") {
15267
+ await this.ec2Client.send(
15268
+ new AuthorizeSecurityGroupEgressCommand({
15269
+ GroupId: groupId,
15270
+ IpPermissions: [this.buildIpPermission(rule, "egress")]
15271
+ })
15272
+ );
15273
+ } else {
15274
+ await this.ec2Client.send(
15275
+ new AuthorizeSecurityGroupIngressCommand({
15276
+ GroupId: groupId,
15277
+ IpPermissions: [this.buildIpPermission(rule, "ingress")]
15278
+ })
15279
+ );
15280
+ }
15281
+ } catch (error) {
15282
+ if (!(error instanceof Error && error.message.includes("already exists"))) {
15283
+ throw error;
15284
+ }
15285
+ }
15286
+ }
15287
+ }
14802
15288
  // ─── AWS::EC2::NetworkAcl ────────────────────────────────────────
14803
15289
  async createNetworkAcl(logicalId, resourceType, properties) {
14804
15290
  this.logger.debug(`Creating NetworkAcl ${logicalId}`);
@@ -16719,7 +17205,7 @@ var CloudFrontDistributionProvider = class {
16719
17205
  this.logger.debug(`Created CloudFront Distribution: ${distributionId} (${domainName})`);
16720
17206
  if (process.env["CDKD_NO_WAIT"] !== "true") {
16721
17207
  this.logger.debug(`Waiting for Distribution ${distributionId} to reach Deployed status...`);
16722
- await this.waitForDistributionDeployed(distributionId);
17208
+ await this.waitForDistributionStable(distributionId);
16723
17209
  }
16724
17210
  return {
16725
17211
  physicalId: distributionId,
@@ -16829,7 +17315,7 @@ var CloudFrontDistributionProvider = class {
16829
17315
  })
16830
17316
  );
16831
17317
  etag = updateResponse.ETag;
16832
- await this.waitForDistributionDeployed(physicalId);
17318
+ await this.waitForDistributionStable(physicalId, false);
16833
17319
  const refreshResponse = await this.cloudFrontClient.send(
16834
17320
  new GetDistributionConfigCommand({ Id: physicalId })
16835
17321
  );
@@ -16873,10 +17359,16 @@ var CloudFrontDistributionProvider = class {
16873
17359
  throw new Error(`Unsupported attribute: ${attributeName} for AWS::CloudFront::Distribution`);
16874
17360
  }
16875
17361
  /**
16876
- * Wait for a distribution to reach "Deployed" status.
17362
+ * Wait for a distribution to reach a stable state.
17363
+ *
17364
+ * "Stable" means Status === 'Deployed'. When `expectedEnabled` is provided,
17365
+ * we additionally require DistributionConfig.Enabled === expectedEnabled —
17366
+ * this guards against CloudFront's eventually-consistent reads that can
17367
+ * briefly return the pre-update snapshot after UpdateDistribution returns.
17368
+ *
16877
17369
  * Uses exponential backoff polling.
16878
17370
  */
16879
- async waitForDistributionDeployed(distributionId) {
17371
+ async waitForDistributionStable(distributionId, expectedEnabled) {
16880
17372
  const maxAttempts = 60;
16881
17373
  let delay = 5e3;
16882
17374
  const maxDelay = 3e4;
@@ -16897,12 +17389,16 @@ var CloudFrontDistributionProvider = class {
16897
17389
  new GetDistributionCommand({ Id: distributionId })
16898
17390
  );
16899
17391
  const status = response.Distribution?.Status;
16900
- if (status === "Deployed") {
16901
- this.logger.debug(`Distribution ${distributionId} is now Deployed`);
17392
+ const enabled = response.Distribution?.DistributionConfig?.Enabled;
17393
+ const enabledMatches = expectedEnabled === void 0 || enabled === expectedEnabled;
17394
+ if (status === "Deployed" && enabledMatches) {
17395
+ this.logger.debug(
17396
+ `Distribution ${distributionId} is stable (Status=Deployed, Enabled=${enabled})`
17397
+ );
16902
17398
  return;
16903
17399
  }
16904
17400
  this.logger.debug(
16905
- `Distribution ${distributionId} status: ${status} (attempt ${attempt}/${maxAttempts})`
17401
+ `Distribution ${distributionId} status: ${status}, enabled: ${enabled}` + (expectedEnabled === void 0 ? "" : ` (waiting for Enabled=${expectedEnabled})`) + ` (attempt ${attempt}/${maxAttempts})`
16906
17402
  );
16907
17403
  const sleepEnd = Date.now() + delay;
16908
17404
  while (Date.now() < sleepEnd && !interrupted) {
@@ -16911,7 +17407,7 @@ var CloudFrontDistributionProvider = class {
16911
17407
  delay = Math.min(delay * 1.5, maxDelay);
16912
17408
  }
16913
17409
  this.logger.debug(
16914
- `Distribution ${distributionId} did not reach Deployed status within timeout, proceeding with deletion attempt`
17410
+ `Distribution ${distributionId} did not reach stable state within timeout, proceeding with next step`
16915
17411
  );
16916
17412
  } finally {
16917
17413
  process.removeListener("SIGINT", sigintHandler);
@@ -24137,7 +24633,7 @@ import {
24137
24633
  DeleteObjectsCommand as DeleteObjectsCommand2
24138
24634
  } from "@aws-sdk/client-s3";
24139
24635
  import { GetCallerIdentityCommand as GetCallerIdentityCommand7 } from "@aws-sdk/client-sts";
24140
- import { EC2Client as EC2Client7, DescribeAvailabilityZonesCommand as DescribeAvailabilityZonesCommand3 } from "@aws-sdk/client-ec2";
24636
+ import { EC2Client as EC2Client8, DescribeAvailabilityZonesCommand as DescribeAvailabilityZonesCommand3 } from "@aws-sdk/client-ec2";
24141
24637
  init_aws_clients();
24142
24638
  var S3DirectoryBucketProvider = class {
24143
24639
  s3Client;
@@ -24155,7 +24651,7 @@ var S3DirectoryBucketProvider = class {
24155
24651
  }
24156
24652
  getEc2Client() {
24157
24653
  if (!this.ec2Client) {
24158
- this.ec2Client = new EC2Client7(this.providerRegion ? { region: this.providerRegion } : {});
24654
+ this.ec2Client = new EC2Client8(this.providerRegion ? { region: this.providerRegion } : {});
24159
24655
  }
24160
24656
  return this.ec2Client;
24161
24657
  }
@@ -25142,6 +25638,44 @@ var DagExecutor = class {
25142
25638
  }
25143
25639
  };
25144
25640
 
25641
+ // src/analyzer/implicit-delete-deps.ts
25642
+ var IMPLICIT_DELETE_DEPENDENCIES = {
25643
+ // IGW must be deleted AFTER VPCGatewayAttachment
25644
+ "AWS::EC2::InternetGateway": ["AWS::EC2::VPCGatewayAttachment"],
25645
+ // EventBus must be deleted AFTER Rules on that bus
25646
+ "AWS::Events::EventBus": ["AWS::Events::Rule"],
25647
+ // Athena workgroup must be deleted AFTER its named queries
25648
+ "AWS::Athena::WorkGroup": ["AWS::Athena::NamedQuery"],
25649
+ // CloudFront managed-policy-style resources must be deleted AFTER
25650
+ // any Distribution that references them
25651
+ "AWS::CloudFront::ResponseHeadersPolicy": ["AWS::CloudFront::Distribution"],
25652
+ "AWS::CloudFront::CachePolicy": ["AWS::CloudFront::Distribution"],
25653
+ "AWS::CloudFront::OriginAccessControl": ["AWS::CloudFront::Distribution"],
25654
+ // VPC must be deleted AFTER all VPC-dependent resources
25655
+ "AWS::EC2::VPC": [
25656
+ "AWS::EC2::Subnet",
25657
+ "AWS::EC2::SecurityGroup",
25658
+ "AWS::EC2::InternetGateway",
25659
+ "AWS::EC2::EgressOnlyInternetGateway",
25660
+ "AWS::EC2::VPCGatewayAttachment",
25661
+ "AWS::EC2::RouteTable"
25662
+ ],
25663
+ // Subnet must be deleted AFTER any Lambda that may still hold an ENI
25664
+ // in it. Lambda DELETE returns immediately but the ENI is detached
25665
+ // asynchronously by AWS, so deleting the Subnet first races the detach
25666
+ // and yields "DependencyViolation".
25667
+ "AWS::EC2::Subnet": ["AWS::EC2::SubnetRouteTableAssociation", "AWS::Lambda::Function"],
25668
+ // RouteTable must be deleted AFTER Route and Association
25669
+ "AWS::EC2::RouteTable": ["AWS::EC2::Route", "AWS::EC2::SubnetRouteTableAssociation"],
25670
+ // SecurityGroup must be deleted AFTER any Lambda whose ENI is bound
25671
+ // to it (same ENI-detach race as Subnet above).
25672
+ "AWS::EC2::SecurityGroup": [
25673
+ "AWS::EC2::SecurityGroupIngress",
25674
+ "AWS::EC2::SecurityGroupEgress",
25675
+ "AWS::Lambda::Function"
25676
+ ]
25677
+ };
25678
+
25145
25679
  // src/deployment/deploy-engine.ts
25146
25680
  var InterruptedError = class extends Error {
25147
25681
  constructor() {
@@ -25149,7 +25683,7 @@ var InterruptedError = class extends Error {
25149
25683
  this.name = "InterruptedError";
25150
25684
  }
25151
25685
  };
25152
- var DeployEngine = class _DeployEngine {
25686
+ var DeployEngine = class {
25153
25687
  constructor(stateBackend, lockManager, dagBuilder, diffCalculator, providerRegistry, options = {}, stackRegion) {
25154
25688
  this.stateBackend = stateBackend;
25155
25689
  this.lockManager = lockManager;
@@ -25986,36 +26520,9 @@ var DeployEngine = class _DeployEngine {
25986
26520
  const deps = parser.extractDependencies(resource);
25987
26521
  return deps.size > 0 ? [...deps] : void 0;
25988
26522
  }
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
- };
26523
+ // Type-based implicit deletion ordering rules are defined in
26524
+ // src/analyzer/implicit-delete-deps.ts so the deploy DELETE phase and the
26525
+ // standalone destroy command apply the same rules.
26019
26526
  /**
26020
26527
  * Build a per-resource map of "must be deleted before me" dependencies for
26021
26528
  * the DELETE phase, derived from state-recorded dependencies plus implicit
@@ -26081,7 +26588,7 @@ var DeployEngine = class _DeployEngine {
26081
26588
  const resource = state.resources[id];
26082
26589
  if (!resource)
26083
26590
  continue;
26084
- const mustDeleteAfter = _DeployEngine.IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26591
+ const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26085
26592
  if (!mustDeleteAfter)
26086
26593
  continue;
26087
26594
  for (const depType of mustDeleteAfter) {
@@ -26867,27 +27374,6 @@ Acquiring lock for stack ${stackName}...`);
26867
27374
  }
26868
27375
  };
26869
27376
  }
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
27377
  const typeToLogicalIds = /* @__PURE__ */ new Map();
26892
27378
  for (const [logicalId, resource] of Object.entries(currentState.resources)) {
26893
27379
  const ids = typeToLogicalIds.get(resource.resourceType) ?? [];
@@ -26895,7 +27381,7 @@ Acquiring lock for stack ${stackName}...`);
26895
27381
  typeToLogicalIds.set(resource.resourceType, ids);
26896
27382
  }
26897
27383
  for (const [logicalId, resource] of Object.entries(currentState.resources)) {
26898
- const mustDeleteAfter = implicitDeleteDeps[resource.resourceType];
27384
+ const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
26899
27385
  if (!mustDeleteAfter)
26900
27386
  continue;
26901
27387
  for (const depType of mustDeleteAfter) {
@@ -27119,7 +27605,7 @@ function reorderArgs(argv) {
27119
27605
  }
27120
27606
  async function main() {
27121
27607
  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");
27608
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.3.1");
27123
27609
  program.addCommand(createBootstrapCommand());
27124
27610
  program.addCommand(createSynthCommand());
27125
27611
  program.addCommand(createDeployCommand());