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.
- cs_image_system/aws_runtime/__init__.py +3 -0
- cs_image_system/aws_runtime/aws_packer_source.py +180 -0
- cs_image_system/aws_runtime/aws_provider_specific_image.py +43 -0
- cs_image_system/aws_runtime/aws_runtime_builders.py +518 -0
- cs_image_system/aws_runtime/aws_runtime_models.py +177 -0
- cs_image_system/aws_runtime/aws_utils.py +476 -0
- cs_image_system/aws_runtime/bootstrap.py +367 -0
- cs_image_system/aws_runtime/main.py +28 -0
- cs_image_system/py.typed +0 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/METADATA +1146 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/RECORD +14 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/entry_points.txt +5 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
+
|