cs-image-system-aws-runtime-plugin 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,177 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from dataclasses import field
6
+ from cs_image_system.base.models.credentials import CredentialsBase
7
+ from cs_image_system.base.models.model_config import CSIS_MODEL_CONFIG
8
+ from pydantic.dataclasses import dataclass # stage 23: validation at construction
9
+ from typing import Any
10
+
11
+ import logging
12
+
13
+ from cs_image_system.aws_runtime import aws_utils
14
+ from cs_image_system.base.helpers.field_helpers import fk_field
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ from cs_image_system.base.constants import DEFAULT, OOPS_DEFAULTS, VCT
19
+ from cs_image_system.base.models.cloud_builder import CloudBuilderModel, CloudNetworkingConfig
20
+
21
+
22
+ AWS: str = "aws"
23
+ AWS_CLI: str = "aws-cli"
24
+
25
+ @dataclass(config=CSIS_MODEL_CONFIG)
26
+ class AwsCloudNetworkingModel(CloudNetworkingConfig):
27
+ """AWS-specific networking configuration data object."""
28
+ # stage 63: `security_group_ids` was validated and counted but never
29
+ # emitted; `addl_security_groups` is the list that reaches the roots, so
30
+ # the other is refused at load (by the base model's unknown-key rule)
31
+ addl_security_groups: list[str] = field(default_factory=list)
32
+ # Security groups whose members may SSH into launched instances (e.g.
33
+ # the Okta gateway relay). When set, the instance SG's port-22 ingress
34
+ # references ONLY these groups -- no CIDR ranges (policy: never open
35
+ # ingress to the shared networks' address space).
36
+ ssh_ingress_security_group_ids: list[str] = field(default_factory=list)
37
+
38
+ _model_id: str | None = fk_field(target=VCT.RUNTIME_BUILDER_MODEL,
39
+ init=False,
40
+ default=None, metadata={
41
+ "description": "The name of the runtime model this object is associated with",
42
+ })
43
+
44
+ def __post_init__(self) -> None:
45
+ super().__post_init__()
46
+ self.name = self.name.strip()
47
+ if not self.name:
48
+ raise ValueError(f"Networking configuration name cannot be empty for {self}.")
49
+ # import uuid
50
+ # id = str(uuid.uuid4())
51
+ # log.warning(f"Networking configuration name is empty or default for {self}."
52
+ # f"This may cause issues with identification and retrieval of this configuration. "
53
+ # f"Assigning a random UUID as name: {id}")
54
+ # self.name = id
55
+
56
+ def get_name(self) -> str:
57
+ return self.name
58
+ def get_subnet_id(self) -> str:
59
+ if not self.default_subnet_id:
60
+ raise ValueError(f"Subnet ID is required for AWS networking configuration {self.name} but is not set.")
61
+ return self.default_subnet_id
62
+ def get_addl_security_groups(self) -> list[str]:
63
+ return self.addl_security_groups
64
+
65
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
66
+ class AwsCredentials(CredentialsBase):
67
+ """What an AWS runtime may declare. The names are boto3 session kwargs, so
68
+ `self_to_aws_client_config` can pass them straight through.
69
+
70
+ Values belong in the environment, not here: `profile_name` names a profile
71
+ and the key fields carry `{{ ENV[...] }}` templates. Stage 17 makes the
72
+ SHAPE checkable -- `profile_nme` is now an error instead of a silent None.
73
+ """
74
+ profile_name: str | None = None
75
+ aws_access_key_id: str | None = None
76
+ aws_secret_access_key: str | None = None
77
+ aws_session_token: str | None = None
78
+
79
+
80
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
81
+ class AwsCloudBuilderModel(CloudBuilderModel):
82
+ """AWS cloud provider configuration data object."""
83
+ type = AWS
84
+ account_id: str | None = None
85
+ state_configuration: str = fk_field(target=VCT.STATE_BACKEND_MODEL,
86
+ default = DEFAULT,
87
+ metadata={
88
+ "description": "The state manager this config uses",
89
+ "required": True,
90
+ })
91
+ # profile: str | None = None # This is for connetivity to AWS
92
+ ena_support: bool | None = None
93
+ sriov_support: bool | None = None
94
+ iam_instance_profile: str | None = None
95
+ # V2 debug access (N9): "ssm" bakes the SSM agent into base images and
96
+ # (when session_instance_profile is set) attaches that profile to
97
+ # launched instances so system-level operators can start a session.
98
+ session_mechanism: str | None = None
99
+ session_instance_profile: str | None = None
100
+ ssh_username: str = DEFAULT # Currently override when needed. (How do we get this? From the image?)
101
+ networking: AwsCloudNetworkingModel | None = None # type: ignore #Overrides parent
102
+ vpc_map: dict[str, dict[str, list[str]]] | None = field(init=False, default=None)
103
+ default_vpc_id: str | None = field(init=False, default=None)
104
+ all_security_groups: dict[str, dict] = field(init=False, default_factory=dict)
105
+
106
+ def __post_init__(self) -> None:
107
+ super().__post_init__()
108
+
109
+ def finalize(self):
110
+ if self._finalized:
111
+ return
112
+ super().finalize()
113
+ self.update_networking()
114
+ @classmethod
115
+ def csis_name(cls) -> str:
116
+ return AWS
117
+
118
+ credentials: AwsCredentials = field(default_factory=AwsCredentials)
119
+
120
+ def self_to_aws_client_config(self) -> dict[str, Any]:
121
+ config: dict[str, Any] = {}
122
+ if self.credentials:
123
+ config.update(self.get_credentials())
124
+ # if self.profile:
125
+ # config["profile_name"] = self.profile
126
+ if self.region:
127
+ config["region_name"] = self.region
128
+ return config
129
+ def update_networking(self) -> None:
130
+ """ Get the VPC map, default VPC and security groups for the AWS account and validate the networking configuration. """
131
+ session_config = self.self_to_aws_client_config()
132
+ if not session_config:
133
+ raise ValueError("Session configuration cannot be None or empty")
134
+
135
+ vpc_map, default_vpc_id, allsgs = aws_utils.get_vpc_map_and_default_vpc_id(session_config)
136
+ self.vpc_map = vpc_map
137
+ self.default_vpc_id = default_vpc_id
138
+ self.all_security_groups = allsgs
139
+
140
+
141
+ if not self.networking:
142
+ log.warning(f"No networking configuration provided for AWS cloud builder {self.get_display_name()}. This is required for image resolution and build execution. Please provide a networking configuration.")
143
+ return
144
+ if self.networking.network in OOPS_DEFAULTS:
145
+ if not default_vpc_id:
146
+ errstr = f"No VPC ID specified in networking configuration for AWS cloud builder {self.name}, and no default VPC found in AWS account. A VPC ID is required for image resolution and build execution. Please specify a VPC ID in the networking configuration or ensure a default VPC exists in the AWS account."
147
+ log.error(errstr)
148
+ raise ValueError(errstr)
149
+ self.networking.network = default_vpc_id
150
+ if self.networking.network not in self.vpc_map:
151
+ errstr = f"VPC ID {self.networking.network} specified in networking configuration for AWS cloud builder {self.name} not found in AWS account."
152
+ log.error(errstr)
153
+ raise ValueError(errstr)
154
+ # stage 63: every declared subnet must be in the declared VPC (a
155
+ # subnet id from another VPC used to pass the load and fail at the
156
+ # first bake or apply). The check runs when the account answered with
157
+ # the VPC's subnets; a VPC listed without any makes no claim.
158
+ vpc_entry: Any = self.vpc_map.get(self.networking.network) or {}
159
+ known = {str(s.get("subnet_id")) for s in vpc_entry.get("subnets", [])}
160
+ if known:
161
+ for subnet in getattr(self.networking, "subnets", None) or []:
162
+ sid = getattr(subnet, "subnet_id", None)
163
+ if sid and sid not in known:
164
+ errstr = (f"Subnet {sid} ({subnet.get_name()}) in networking configuration for AWS cloud "
165
+ f"builder {self.name} is not in VPC {self.networking.network}.")
166
+ log.error(errstr)
167
+ raise ValueError(errstr)
168
+ total_sgs = list(self.networking.addl_security_groups)
169
+ for sg in total_sgs:
170
+ if sg not in self.all_security_groups:
171
+ errstr = f"Security group ID {sg} specified in networking configuration for AWS cloud builder {self.name} not found in AWS account."
172
+ log.error(errstr)
173
+ raise ValueError(errstr)
174
+ if len(total_sgs) > 5:
175
+ errstr = f"Total number of security groups specified in networking configuration for AWS cloud builder {self.name} is {len(total_sgs)}, which may exceed limits for certain instance types. Please ensure this is intentional and does not exceed limits for your target instance types."
176
+ log.error(errstr)
177
+ raise ValueError(errstr)
@@ -0,0 +1,476 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """AWS utility functions for EC2 and AMI queries."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import logging
11
+ from typing import Any, Sequence, cast
12
+ import boto3 # type: ignore[import-untyped]
13
+ from botocore.client import BaseClient as EC2 # type: ignore[import-untyped]
14
+ from botocore.exceptions import BotoCoreError, ClientError
15
+
16
+ from cs_image_system.base.models.os_builder_runtime_config import OSBuilderBaseImageBuilderSubconfig
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ class AMINotFoundError(Exception):
21
+ """Raised when no AMI is found matching the query."""
22
+
23
+ pass
24
+
25
+
26
+ class AMIQueryError(Exception):
27
+ """Raised when an AMI query fails."""
28
+
29
+ pass
30
+
31
+
32
+ AWS_DI_MAP: dict[str, str] = {
33
+ "executable-users": "ExecutableUsers",
34
+ "owners": "Owners",
35
+ "filters": "Filters",
36
+ "image_ids": "ImageIds",
37
+ # The image architecture ( i386 | x86_64 | arm64 | x86_64_mac | arm64_mac).
38
+ "architecture": "architecture",
39
+ # A Boolean value that indicates whether the Amazon EBS volume is deleted
40
+ # on instance termination.
41
+ "block_device_mapping.delete_on_termination": (
42
+ "block-device-mapping.delete-on-termination"
43
+ ),
44
+ # The device name specified in the block device mapping
45
+ # (for example, /dev/sdh or xvdh).
46
+ "block_device_mapping.device_name": "block-device-mapping.device-name",
47
+ # The ID of the snapshot used for the Amazon EBS volume.
48
+ "block_device_mapping.snapshot_id": "block-device-mapping.snapshot-id",
49
+ # The volume size of the Amazon EBS volume, in GiB.
50
+ "block_device_mapping.volume_size": "block-device-mapping.volume-size",
51
+ # The volume type of the Amazon EBS volume
52
+ # ( io1 | io2 | gp2 | gp3 | sc1 | st1 | standard).
53
+ "block_device_mapping.volume_type": "block-device-mapping.volume-type",
54
+ # A Boolean that indicates whether the Amazon EBS volume is encrypted.
55
+ "block_device_mapping.encrypted": "block-device-mapping.encrypted",
56
+ # The time when the image was created, in the ISO 8601 format in the UTC
57
+ # time zone (YYYY-MM-DDThh:mm:ss.sssZ), for example,
58
+ # 2021-09-29T11:04:43.305Z. You can use a wildcard ( *), for example,
59
+ # 2021-09-29T*, which matches an entire day.
60
+ "creation_date": "creation-date",
61
+ # The description of the image (provided during image creation).
62
+ "description": "description",
63
+ # A Boolean that indicates whether enhanced networking with ENA is enabled.
64
+ "ena_support": "ena-support",
65
+ # A Boolean that indicates whether this image can be used under the Amazon
66
+ # Web Services Free Tier ( true | false).
67
+ "free_tier_eligible": "free-tier-eligible",
68
+ # The hypervisor type ( ovm | xen).
69
+ "hypervisor": "hypervisor",
70
+ # A Boolean that indicates whether the image meets the criteria specified
71
+ # for Allowed AMIs.
72
+ "image_allowed": "image-allowed",
73
+ "image_id": "image-id", # The ID of the image.
74
+ "image_type": "image-type", # The image type ( machine | kernel | ramdisk).
75
+ "is_public": "is-public", # A Boolean that indicates whether the image is public.
76
+ "kernel_id": "kernel-id", # The kernel ID.
77
+ "manifest_location": "manifest-location", # The location of the image manifest.
78
+ "name": "name", # The name of the AMI (provided during image creation).
79
+ # The owner alias ( amazon | aws-backup-vault | aws-marketplace). The valid
80
+ # aliases are defined in an Amazon-maintained list. This is not the Amazon
81
+ # Web Services account alias that can be set using the IAM console.
82
+ # We recommend that you use the Owner request parameter instead of this filter.
83
+ "owner_alias": "owner-alias",
84
+ # The Amazon Web Services account ID of the owner. We recommend that you
85
+ # use the Owner request parameter instead of this filter.
86
+ "owner_id": "owner-id",
87
+ "platform": "platform", # The platform. The only supported value is windows.
88
+ "product_code": "product-code", # The product code.
89
+ # The type of the product code ( marketplace).
90
+ "product_code.type_": "product-code.type_",
91
+ "ramdisk_id": "ramdisk-id", # The RAM disk ID.
92
+ # The device name of the root device volume (for example, /dev/sda1).
93
+ "root_device_name": "root-device-name",
94
+ # The type of the root device volume ( ebs | instance-store).
95
+ "root_device_type": "root-device-type",
96
+ # The ID of the source AMI from which the AMI was created.
97
+ "source_image_id": "source-image-id",
98
+ "source_image_region": "source-image-region", # The Region of the source AMI.
99
+ # The ID of the instance that the AMI was created from if the AMI was
100
+ # created using CreateImage. This filter is applicable only if the AMI
101
+ # was created using CreateImage.
102
+ "source_instance_id": "source-instance-id",
103
+ "state": "state", # The state of the image ( available | pending | failed).
104
+ "state_reason_code": "state-reason-code", # The reason code for the state change.
105
+ "state_reason_message": "state-reason-message", # The message for the state change.
106
+ # A value of simple indicates that enhanced networking with the Intel
107
+ # 82599 VF interface is enabled.
108
+ "sriov_net_support": "sriov-net-support",
109
+ # "tag:<key>" : "tag:<key>",
110
+ # # The key/value combination of a tag assigned to the resource. Use the
111
+ # # tag key in the filter name and the tag value as the filter value.
112
+ # # For example, to find all resources that have a tag with the key Owner
113
+ # # and the value TeamA, specify tag:Owner for the filter name and TeamA
114
+ # # for the filter value.
115
+ # The key of a tag assigned to the resource. Use this filter to find all
116
+ # resources assigned a tag with a specific key, regardless of the tag value.
117
+ "tag_key": "tag-key",
118
+ # The virtualization type ( paravirtual | hvm).
119
+ "virtualization_type": "virtualization-type",
120
+ }
121
+
122
+
123
+ def remap_for_image_query(rc: OSBuilderBaseImageBuilderSubconfig) -> tuple[dict[str, Any], dict[str, Any]]:
124
+ ret: dict[str, Any] = {
125
+ "DryRun": False,
126
+ "IncludeDisabled": False,
127
+ "IncludeDeprecated": False,
128
+ }
129
+ owners: Sequence[Any] = rc.get_owners() if rc else []
130
+ ret["Owners"] = owners # completely overridden if specified in config
131
+ missed: dict[str, Any] = {}
132
+ # stage 63: a deep copy -- the loop below rewrites the filters mapping in
133
+ # place, and get_query() hands back the entry's own nested dicts, so the
134
+ # model's query used to be mutated by every resolution
135
+ for topk, topv in copy.deepcopy(dict(rc.get_query())).items():
136
+ _key2 = AWS_DI_MAP.get(topk, None)
137
+ if not _key2:
138
+ missed[topk] = topv
139
+ continue
140
+ topk = _key2
141
+ if topk == "Owners":
142
+ if isinstance(topv, list):
143
+ topv = [str(i) for i in topv]
144
+ else:
145
+ topv = [str(topv)]
146
+ elif topk == "Filters":
147
+ if isinstance(topv, dict):
148
+ topv["state"] = (
149
+ "available" # Ensure we only get available images, but allow override
150
+ )
151
+ new_dict: dict[str, Any] = {}
152
+ for _k, _v in topv.items():
153
+ _k = AWS_DI_MAP.get(_k, _k)
154
+ assert _k is not None, "Bad Filter key is None"
155
+ if _k == "Owners":
156
+ if isinstance(_v, list):
157
+ _v = [str(i) for i in _v]
158
+ else:
159
+ _v = [str(_v)]
160
+ if _k.startswith("tag:"):
161
+ _k = f"tag:{_k[4:]}"
162
+ if isinstance(_v, bool):
163
+ _v = str(_v).lower()
164
+ new_dict[_k] = _v
165
+ v = new_dict
166
+ topv = [
167
+ {
168
+ "Name": AWS_DI_MAP.get(_k, _k),
169
+ "Values": _v if isinstance(_v, list) else [str(_v)],
170
+ }
171
+ for _k, _v in v.items()
172
+ ]
173
+ else:
174
+ raise ValueError(f"Expected 'filter' value to be a dict, got {type(topv)}")
175
+ ret[topk] = topv
176
+ return (ret, missed)
177
+
178
+
179
+ def query_image(
180
+ query: dict[str, Any],
181
+ post_query_filter: dict[str, Any] | None = None,
182
+ session_config: dict[str, Any] | None = None,
183
+ ) -> dict[str, Any] | None:
184
+ """The newest AMI matching an EC2 ``DescribeImages`` query.
185
+
186
+ ``query`` is what ``remap_for_image_query`` builds from an OS builder
187
+ entry: ``Owners``, ``Filters`` (``[{"Name": ..., "Values": [...]}]``) and
188
+ the ``DryRun``/``IncludeDisabled``/``IncludeDeprecated`` flags. Every
189
+ match is kept that agrees with ``post_query_filter`` (a mapping of AMI
190
+ record keys to required values); the newest by ``CreationDate`` is
191
+ returned, or None when nothing matches. ``session_config`` is the
192
+ runtime's boto3 session configuration and is required. A pinned
193
+ image id does not come through here (stage 63 reads the entry's
194
+ ``image_id`` through ``query_image_by_id``).
195
+ """
196
+ if not query:
197
+ raise ValueError("Query dictionary cannot be empty")
198
+
199
+ try:
200
+ if not session_config:
201
+ raise ValueError("Session configuration cannot be None or empty")
202
+ session = boto3.Session(**session_config)
203
+
204
+ # 2. Create the client from the custom session
205
+ ec2_client = session.client("ec2")
206
+
207
+ except (BotoCoreError, ClientError) as e:
208
+ raise AMIQueryError(f"Failed to create EC2 client: {e}") from e
209
+
210
+ results = _query_by_filters(ec2_client, query)
211
+ # Return most recently created AMI if multiple matches
212
+ ilist = []
213
+ for r in results:
214
+ if post_query_filter:
215
+ match = True
216
+ for k, v in post_query_filter.items():
217
+ if r.get(k) != v:
218
+ match = False
219
+ break
220
+ if not match:
221
+ continue
222
+ ilist.append(r)
223
+ if not ilist:
224
+ return None
225
+ if len(ilist) > 1:
226
+ ilist.sort(key=lambda x: x.get("CreationDate", ""), reverse=True)
227
+
228
+ return cast(dict[str, Any], ilist[0])
229
+
230
+
231
+ def _query_by_ami_id(ec2_client: Any, ami_id: str) -> dict[str, Any] | None:
232
+ """
233
+ Query for an AMI by its exact ID.
234
+
235
+ Parameters
236
+ ----------
237
+ ec2_client : Any
238
+ Boto3 EC2 client instance
239
+ ami_id : str
240
+ The exact AMI ID to retrieve
241
+
242
+ Returns
243
+ -------
244
+ dict[str, Any] | None
245
+ The AMI details dictionary, or None if not found
246
+
247
+ Raises
248
+ ------
249
+ AMIQueryError
250
+ If the API call fails
251
+ """
252
+ try:
253
+ response = ec2_client.describe_images(ImageIds=[ami_id])
254
+ except ClientError as e:
255
+ error_code = e.response.get("Error", {}).get("Code", "")
256
+ is_not_found = "InvalidImageID.NotFound" in error_code
257
+ is_malformed = "InvalidImageID.Malformed" in error_code
258
+ if is_not_found or is_malformed:
259
+ return None
260
+ raise AMIQueryError(f"Failed to query AMI by ID: {e}") from e
261
+ except BotoCoreError as e:
262
+ raise AMIQueryError(f"Failed to query AMI by ID: {e}") from e
263
+
264
+ if not response.get("Images"):
265
+ return None
266
+
267
+ return cast(dict[str, Any], response["Images"][0])
268
+
269
+
270
+
271
+ def _query_by_filters(ec2_client: EC2, query: dict[str, Any]) -> list[dict[str, Any]]:
272
+ """
273
+ Query for an AMI using filter parameters.
274
+
275
+ Builds EC2 filters from the query dictionary and searches for matching AMIs.
276
+ Supports standard AMI filters and tag-based filters.
277
+
278
+ Parameters
279
+ ----------
280
+ ec2_client : Any
281
+ Boto3 EC2 client instance
282
+ query : dict[str, Any]
283
+ Query parameters containing filter criteria. Excludes "ami_id" and
284
+ "ami_name" which are handled elsewhere.
285
+
286
+ Returns
287
+ -------
288
+ dict[str, Any] | None
289
+ The most recently created AMI matching all filters, or None if not found
290
+
291
+ Raises
292
+ ------
293
+ AMIQueryError
294
+ If the API call fails
295
+ ValueError
296
+ If no valid filters are provided
297
+ """
298
+ if not query:
299
+ raise ValueError("No query filters provided")
300
+
301
+ ilist = []
302
+ done: bool = False
303
+ log.debug(f"Querying for AMI with filters: {query}")
304
+ while not done:
305
+ log.debug(".")
306
+ try:
307
+ response = ec2_client.describe_images(**query)
308
+ if response.get("Images"):
309
+ ilist.extend(response.get("Images"))
310
+ # pprint.pprint(response, indent=2, sort_dicts=True)
311
+ if response.get("NextToken"):
312
+ query["NextToken"] = response["NextToken"]
313
+ else:
314
+ done = True
315
+ # images = response["Images"]
316
+ except (ClientError, BotoCoreError) as e:
317
+ raise AMIQueryError(f"Failed to query AMI with filters: {e}") from e
318
+ log.info(f"Found {len(ilist)} AMIs matching filters.")
319
+ return ilist
320
+
321
+
322
+
323
+ def ec2_client(session_config: dict[str, Any] | None) -> Any:
324
+ """A boto3 client bound to the builder's session config; the seam the
325
+ read-only state queries (and their tests) go through."""
326
+ return boto3.Session(**(session_config or {})).client("ec2")
327
+
328
+
329
+ def aws_client(service: str, session_config: dict[str, Any] | None) -> Any:
330
+ return boto3.Session(**(session_config or {})).client(service)
331
+
332
+
333
+ def get_vpc_map_and_default_vpc_id(session_config: dict[str, Any]) -> tuple[dict, str | None, dict]:
334
+ try:
335
+ # Prevent 'multiple values for keyword argument region_name'
336
+ config = session_config.copy()
337
+ session = boto3.Session(**config)
338
+ ec2 = session.client("ec2")
339
+
340
+ # Fetch data in bulk
341
+ vpcs = ec2.describe_vpcs()["Vpcs"]
342
+ subnets = ec2.describe_subnets()["Subnets"]
343
+ route_tables = ec2.describe_route_tables()["RouteTables"]
344
+
345
+ # Moved outside loop for performance
346
+ allsgs = {
347
+ sg["GroupId"]: sg
348
+ for sg in ec2.describe_security_groups()["SecurityGroups"]
349
+ }
350
+
351
+ vpc_map = {}
352
+ default_vpc_id = None
353
+
354
+ # Initialize VPC map with a flat subnets list
355
+ for vpc in vpcs:
356
+ v_id = vpc["VpcId"]
357
+ vpc_map[v_id] = {"subnets": []}
358
+ if vpc.get("IsDefault"):
359
+ default_vpc_id = v_id
360
+
361
+ # --- ROUTE TABLE MAPPING LOGIC ---
362
+ subnet_to_routes = {}
363
+ vpc_to_main_routes = {}
364
+
365
+ for rt in route_tables:
366
+ v_id = rt["VpcId"]
367
+ routes = rt.get("Routes", [])
368
+
369
+ for assoc in rt.get("Associations", []):
370
+ if assoc.get("Main"):
371
+ vpc_to_main_routes[v_id] = routes
372
+
373
+ s_id = assoc.get("SubnetId")
374
+ if s_id:
375
+ subnet_to_routes[s_id] = routes
376
+
377
+ def _is_public(routes: list) -> bool:
378
+ for r in routes:
379
+ gw_id = r.get("GatewayId", "")
380
+ is_default = (r.get("DestinationCidrBlock") == "0.0.0.0/0" or
381
+ r.get("DestinationIpv6CidrBlock") == "::/0")
382
+
383
+ if is_default:
384
+ # Including both igw- and your corporate vgw- routing strategy
385
+ if gw_id.startswith("igw-") or gw_id.startswith("vgw-"):
386
+ return True
387
+ return False
388
+
389
+ # --- PROCESS SUBNETS ---
390
+ for sn in subnets:
391
+ s_id = sn["SubnetId"]
392
+ v_id = sn["VpcId"]
393
+ cidr = sn.get("CidrBlock")
394
+
395
+ if v_id not in vpc_map:
396
+ continue
397
+
398
+ # Route evaluation (Specific -> Main -> Empty Fallback)
399
+ effective_routes = subnet_to_routes.get(s_id) or vpc_to_main_routes.get(v_id, [])
400
+ is_pub = _is_public(effective_routes)
401
+
402
+ # Extract and separate requested attributes
403
+ subnet_data = sn.copy()
404
+ subnet_data.pop("SubnetId", None)
405
+ subnet_data.pop("CidrBlock", None)
406
+
407
+ # Pull AWS Tags list out of metadata
408
+ aws_tags = subnet_data.pop("Tags", [])
409
+
410
+ # Convert [{'Key': 'Name', 'Value': 'MySubnet'}] -> {'Name': 'MySubnet'}
411
+ flattened_tags = {tag["Key"]: tag["Value"] for tag in aws_tags if "Key" in tag and "Value" in tag}
412
+
413
+ # Format the subnet object exactly as specified
414
+ formatted_subnet = {
415
+ "subnet_id": s_id,
416
+ "cidr": cidr,
417
+ "is_public": is_pub,
418
+ "tags": flattened_tags, # Transformed into a clean dict[str, str]
419
+ "config": subnet_data # Stuffs all other remaining raw attributes here
420
+ }
421
+
422
+ vpc_map[v_id]["subnets"].append(formatted_subnet)
423
+
424
+ return vpc_map, default_vpc_id, allsgs
425
+
426
+ except (BotoCoreError, ClientError) as e:
427
+ raise ValueError(f"AWS Error: {e}") from e# # 1. Get all subnets in the VPC
428
+ # subnets = ec2.describe_subnets(
429
+ # Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]
430
+ # )['Subnets']
431
+
432
+ # # 2. Get all route tables in the VPC
433
+ # route_tables = ec2.describe_route_tables(
434
+ # Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]
435
+ # )['RouteTables']
436
+
437
+ # public_subnets = []
438
+ # private_subnets = []
439
+
440
+ # for subnet in subnets:
441
+ # subnet_id = subnet['SubnetId']
442
+ # is_public = False
443
+
444
+ # # 3. Find the route table associated with this subnet
445
+ # # Specific associations take priority over the main route table
446
+ # specific_rt = None
447
+ # main_rt = None
448
+
449
+ # for rt in route_tables:
450
+ # for assoc in rt.get('Associations', []):
451
+ # if assoc.get('SubnetId') == subnet_id:
452
+ # specific_rt = rt
453
+ # break
454
+ # if assoc.get('Main'):
455
+ # main_rt = rt
456
+ # if specific_rt:
457
+ # break
458
+
459
+ # # Use the specific RT if found, otherwise fall back to the Main RT
460
+ # rt_to_check = specific_rt if specific_rt else main_rt
461
+
462
+ # if rt_to_check:
463
+ # for route in rt_to_check.get('Routes', []):
464
+ # # Check for a route to an Internet Gateway
465
+ # gateway_id = route.get('GatewayId', '')
466
+ # if gateway_id.startswith('igw-'):
467
+ # is_public = True
468
+ # break
469
+
470
+ # if is_public:
471
+ # public_subnets.append(subnet_id)
472
+ # else:
473
+ # private_subnets.append(subnet_id)
474
+
475
+ # return public_subnets, private_subnets
476
+