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,518 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
|
|
11
|
+
from cs_image_system.base.basic.asset import AssetSet
|
|
12
|
+
from cs_image_system.base.basic.builder_base_cloud import CloudBuilderBase
|
|
13
|
+
from cs_image_system.base.constants import VCT
|
|
14
|
+
from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
|
|
15
|
+
from cs_image_system.base.models.os_builder_runtime_config import OSBuilderBaseImageBuilderSubconfig
|
|
16
|
+
from cs_image_system.base.models.provider_specific_image import ProviderSpecificImage
|
|
17
|
+
from cs_image_system.base import power_state
|
|
18
|
+
from cs_image_system.base.protocols.plugin_metadata import PluginArtifactProtocol
|
|
19
|
+
|
|
20
|
+
from .aws_provider_specific_image import AwsProviderSpecificImage
|
|
21
|
+
from .aws_runtime_models import AWS_CLI, AwsCloudBuilderModel
|
|
22
|
+
from .aws_utils import remap_for_image_query, query_image
|
|
23
|
+
from cs_image_system.aws_runtime import aws_utils
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
AWS_RUNTIME: str = "aws"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AwsCloudBuilder(CloudBuilderBase[AwsCloudBuilderModel], PluginArtifactProtocol):
|
|
33
|
+
"""AWS cloud provider implementation."""
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def csis_name(cls) -> str:
|
|
37
|
+
return AWS_RUNTIME
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def model(self) -> AwsCloudBuilderModel:
|
|
41
|
+
return self._model # type: ignore
|
|
42
|
+
|
|
43
|
+
def provider_specific_image_class(self) -> type[ProviderSpecificImage]:
|
|
44
|
+
return AwsProviderSpecificImage
|
|
45
|
+
|
|
46
|
+
def query_provider_image(self, os_builder: OSBuilderBaseImageBuilderSubconfig) -> tuple[str, str, Mapping[str,Any]] | None:
|
|
47
|
+
retval: str | None = None
|
|
48
|
+
q,missed = remap_for_image_query(os_builder) if os_builder else ({}, {})
|
|
49
|
+
kv = query_image(
|
|
50
|
+
query=q,
|
|
51
|
+
post_query_filter=missed,
|
|
52
|
+
session_config=self.model.self_to_aws_client_config(),
|
|
53
|
+
)
|
|
54
|
+
if kv:
|
|
55
|
+
retval = kv.get("ImageId", None)
|
|
56
|
+
else:
|
|
57
|
+
kv = {}
|
|
58
|
+
|
|
59
|
+
regstr = "self"
|
|
60
|
+
if retval:
|
|
61
|
+
region = self.model.get_region()
|
|
62
|
+
if not region:
|
|
63
|
+
raise ValueError(f"Region must be specified in builder config to resolve image identifier, but got {region}")
|
|
64
|
+
# the owner's alias (e.g. `amazon`) when AWS gives one, else the account id
|
|
65
|
+
_r = kv.get("ImageOwnerAlias") or kv.get("OwnerId")
|
|
66
|
+
if not _r:
|
|
67
|
+
raise ValueError(f"Could not get owner alias or owner id for image {retval} in region {region}, got {kv}")
|
|
68
|
+
regstr = str(_r)
|
|
69
|
+
return (retval,regstr, kv) if retval else None
|
|
70
|
+
|
|
71
|
+
def query_provider_image_by_id(self, os_builder: OSBuilderBaseImageBuilderSubconfig,
|
|
72
|
+
image_id: str) -> tuple[str, str, Mapping[str, Any]] | None:
|
|
73
|
+
"""The AMI an entry pins by ``image_id`` (stage 63), looked up with
|
|
74
|
+
DescribeImages by id; its owner is the alias when AWS gives one."""
|
|
75
|
+
kv = aws_utils._query_by_ami_id(aws_utils.ec2_client(self.model.self_to_aws_client_config()), image_id)
|
|
76
|
+
if not kv:
|
|
77
|
+
return None
|
|
78
|
+
owner = kv.get("ImageOwnerAlias") or kv.get("OwnerId") or "self"
|
|
79
|
+
return (str(kv.get("ImageId") or image_id), str(owner), kv)
|
|
80
|
+
|
|
81
|
+
# def resolve_image_for_os_builder(
|
|
82
|
+
# self, os_builder: OsBuilderProtocol
|
|
83
|
+
# ) -> Image | None:
|
|
84
|
+
# ret: Image | None = None
|
|
85
|
+
# rc = os_builder.get_configs_for_runtimes().get(self.name,None)
|
|
86
|
+
# assert rc is not None, f"No runtime configuration found for runtime type {self.name} in OS builder {os_builder.get_name()}"
|
|
87
|
+
# # q, missed = aws_utils.remap_for_image_query(rc)
|
|
88
|
+
# # kv = aws_utils.query_image(
|
|
89
|
+
# # query=q,
|
|
90
|
+
# # post_query_filter=missed,
|
|
91
|
+
# # session_config=self.model.self_to_aws_client_config(),
|
|
92
|
+
# # )
|
|
93
|
+
|
|
94
|
+
# kv = os_builder.get_runtime_config_query_results(self)
|
|
95
|
+
# if kv:
|
|
96
|
+
# ret = image_from_query_result( # stage 24: that function is gone; see aws_utilskv, os_builder, self)
|
|
97
|
+
# return ret
|
|
98
|
+
|
|
99
|
+
def query_images(self, series: list[str]) -> list[dict[str, Any]]:
|
|
100
|
+
"""Every AMI we own that carries a ``csis_series`` tag (read-only)."""
|
|
101
|
+
from cs_image_system.base.lineage import TAG_PREFIX
|
|
102
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
103
|
+
out: list[dict[str, Any]] = []
|
|
104
|
+
paginator = ec2.get_paginator("describe_images")
|
|
105
|
+
for page in paginator.paginate(Owners=["self"],
|
|
106
|
+
Filters=[{"Name": "tag-key", "Values": [f"{TAG_PREFIX}series"]}]):
|
|
107
|
+
for img in page.get("Images", []):
|
|
108
|
+
tags = {t["Key"]: t["Value"] for t in img.get("Tags", []) if "Key" in t}
|
|
109
|
+
out.append({"image_id": img.get("ImageId"), "name": img.get("Name"),
|
|
110
|
+
"state": img.get("State"), "created": img.get("CreationDate"),
|
|
111
|
+
"tags": tags})
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
# ----------------------------------------------- AWS parity (stage 11.1)
|
|
115
|
+
def _ours(self, instances: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
116
|
+
"""Stage 93: of the machines found by Name, the ones this
|
|
117
|
+
configuration may take for its own -- tagged with its id, or with no
|
|
118
|
+
id at all (launched before the stage). Another configuration's
|
|
119
|
+
machine of the same name is not ours to read, stop or log into."""
|
|
120
|
+
from cs_image_system.base.lineage import ours_or_unlabelled
|
|
121
|
+
ctx = self._get_context()
|
|
122
|
+
return [i for i in instances
|
|
123
|
+
if ours_or_unlabelled(ctx, {t["Key"]: t["Value"] for t in i.get("Tags", []) if "Key" in t})]
|
|
124
|
+
|
|
125
|
+
def instance_tags(self, instance_name: str) -> dict[str, str] | None:
|
|
126
|
+
"""The tags the named machine carries (stage 93's relabel reads them);
|
|
127
|
+
None when no such machine stands."""
|
|
128
|
+
inst = self._named_instance(instance_name)
|
|
129
|
+
if inst is None:
|
|
130
|
+
return None
|
|
131
|
+
return {t["Key"]: t["Value"] for t in inst.get("Tags", []) if "Key" in t}
|
|
132
|
+
|
|
133
|
+
def retag_instance(self, instance_name: str, tags: dict[str, str]) -> bool:
|
|
134
|
+
"""``create-tags`` on the named machine (stage 93: the id, in place)."""
|
|
135
|
+
inst = self._named_instance(instance_name)
|
|
136
|
+
if inst is None or not inst.get("InstanceId"):
|
|
137
|
+
return False
|
|
138
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
139
|
+
ec2.create_tags(Resources=[str(inst["InstanceId"])], Tags=[{"Key": k, "Value": str(v)} for k, v in tags.items()])
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def _running_instance(self, instance_name: str) -> dict[str, Any] | None:
|
|
143
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
144
|
+
res = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [instance_name]},
|
|
145
|
+
{"Name": "instance-state-name", "Values": ["pending", "running"]}])
|
|
146
|
+
found = self._ours([i for r in res.get("Reservations", []) for i in r.get("Instances", [])])
|
|
147
|
+
return found[0] if found else None
|
|
148
|
+
|
|
149
|
+
def can_query_instance_boot_image(self) -> bool:
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def query_instance_boot_image(self, instance_name: str) -> str | None:
|
|
153
|
+
"""The AMI the named instance runs (its ``ImageId``); None when no
|
|
154
|
+
such instance is running -- read-only, never fatal."""
|
|
155
|
+
try:
|
|
156
|
+
inst = self._running_instance(instance_name)
|
|
157
|
+
return str(inst.get("ImageId")) if inst and inst.get("ImageId") else None
|
|
158
|
+
except Exception as e: # noqa: BLE001 - read-only probe
|
|
159
|
+
log.debug(f"AWS runtime {self.get_name()}: boot image of {instance_name!r} unavailable: {e}")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------ power state (stage 57)
|
|
163
|
+
#: EC2 ``State.Name`` mapped onto the system's vocabulary. The mapping
|
|
164
|
+
#: lives here because this is where the cloud knowledge belongs; nothing
|
|
165
|
+
#: outside this plugin sees an EC2 spelling. Note ``terminated``: the
|
|
166
|
+
#: record lingers in describe_instances for about an hour after the
|
|
167
|
+
#: machine is gone, and a lingering record is not a machine.
|
|
168
|
+
_EC2_POWER = {
|
|
169
|
+
"pending": power_state.STARTING,
|
|
170
|
+
"running": power_state.RUNNING,
|
|
171
|
+
"stopping": power_state.STOPPING,
|
|
172
|
+
"stopped": power_state.STOPPED,
|
|
173
|
+
"shutting-down": power_state.STOPPING,
|
|
174
|
+
"terminated": power_state.ABSENT,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def _named_instance(self, instance_name: str) -> dict[str, Any] | None:
|
|
178
|
+
"""The instance with this Name tag WHATEVER its state -- unlike
|
|
179
|
+
``_running_instance``, which filters on pending/running and so cannot
|
|
180
|
+
see a machine that is merely switched off. A terminated record is
|
|
181
|
+
used only when there is nothing else, since AWS keeps it visible for
|
|
182
|
+
about an hour after the machine has gone."""
|
|
183
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
184
|
+
res = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [instance_name]}])
|
|
185
|
+
found = self._ours([i for r in res.get("Reservations", []) for i in r.get("Instances", [])])
|
|
186
|
+
if not found:
|
|
187
|
+
return None
|
|
188
|
+
live = [i for i in found
|
|
189
|
+
if str((i.get("State") or {}).get("Name")) != "terminated"]
|
|
190
|
+
return (live or found)[0]
|
|
191
|
+
|
|
192
|
+
def can_query_instance_power_state(self) -> bool:
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def query_instance_power_state(self, instance_name: str) -> str | None:
|
|
196
|
+
"""EC2's own ``State.Name`` for the named instance. None means this
|
|
197
|
+
runtime could not find out (credentials, network, permissions) --
|
|
198
|
+
never that the machine is off; a machine that is off answers
|
|
199
|
+
``STOPPED``."""
|
|
200
|
+
try:
|
|
201
|
+
inst = self._named_instance(instance_name)
|
|
202
|
+
if inst is None:
|
|
203
|
+
return power_state.ABSENT
|
|
204
|
+
raw = str((inst.get("State") or {}).get("Name") or "")
|
|
205
|
+
mapped = self._EC2_POWER.get(raw)
|
|
206
|
+
if mapped is None:
|
|
207
|
+
log.warning(f"AWS runtime {self.get_name()}: instance {instance_name!r} reports "
|
|
208
|
+
f"unrecognised state {raw!r}; treating it as unknown")
|
|
209
|
+
return power_state.UNKNOWN
|
|
210
|
+
return mapped
|
|
211
|
+
except Exception as e: # noqa: BLE001 - read-only probe: unavailable, never fatal
|
|
212
|
+
log.debug(f"AWS runtime {self.get_name()}: power state of {instance_name!r} unavailable: {e}")
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def can_query_instance_identity(self) -> bool:
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def query_instance_identity(self, instance_name: str) -> dict[str, str] | None:
|
|
219
|
+
"""``InstanceId`` and ``PrivateDnsName`` of the named instance,
|
|
220
|
+
whatever its power state (stage 58) -- ``_named_instance`` sees a
|
|
221
|
+
stopped machine, ``_running_instance`` does not."""
|
|
222
|
+
try:
|
|
223
|
+
inst = self._named_instance(instance_name)
|
|
224
|
+
if inst is None or not inst.get("InstanceId"):
|
|
225
|
+
return None
|
|
226
|
+
return {"instance_id": str(inst["InstanceId"]),
|
|
227
|
+
"provider_hostname": str(inst.get("PrivateDnsName") or "")}
|
|
228
|
+
except Exception as e: # noqa: BLE001 - read-only probe
|
|
229
|
+
log.debug(f"AWS runtime {self.get_name()}: identity of {instance_name!r} unavailable: {e}")
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def can_set_instance_power_state(self) -> bool:
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
def start_instance(self, instance_name: str, timeout: int = 300) -> bool:
|
|
236
|
+
inst = self._named_instance(instance_name)
|
|
237
|
+
if inst is None:
|
|
238
|
+
raise RuntimeError(f"AWS runtime {self.get_name()}: no instance named {instance_name!r} to start")
|
|
239
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
240
|
+
iid = str(inst["InstanceId"])
|
|
241
|
+
ec2.start_instances(InstanceIds=[iid])
|
|
242
|
+
ec2.get_waiter("instance_running").wait(
|
|
243
|
+
InstanceIds=[iid], WaiterConfig={"Delay": 5, "MaxAttempts": max(1, timeout // 5)})
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
def stop_instance(self, instance_name: str, timeout: int = 300) -> bool:
|
|
247
|
+
inst = self._named_instance(instance_name)
|
|
248
|
+
if inst is None:
|
|
249
|
+
raise RuntimeError(f"AWS runtime {self.get_name()}: no instance named {instance_name!r} to stop")
|
|
250
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
251
|
+
iid = str(inst["InstanceId"])
|
|
252
|
+
ec2.stop_instances(InstanceIds=[iid])
|
|
253
|
+
ec2.get_waiter("instance_stopped").wait(
|
|
254
|
+
InstanceIds=[iid], WaiterConfig={"Delay": 5, "MaxAttempts": max(1, timeout // 5)})
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
def dispose_image(self, build_id: str) -> bool:
|
|
258
|
+
"""Deregister the AMI and delete the EBS snapshots it was made of --
|
|
259
|
+
the finding-46 shape done by hand for two AMIs on 2026-09-05.
|
|
260
|
+
Not found = already gone (the record is still dropped)."""
|
|
261
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
262
|
+
try:
|
|
263
|
+
images = ec2.describe_images(ImageIds=[build_id]).get("Images", [])
|
|
264
|
+
except Exception as e: # noqa: BLE001
|
|
265
|
+
if "InvalidAMIID" in str(e) or "NotFound" in str(e):
|
|
266
|
+
log.warning(f"AMI {build_id} not found; nothing to deregister")
|
|
267
|
+
return False
|
|
268
|
+
raise
|
|
269
|
+
if not images:
|
|
270
|
+
log.warning(f"AMI {build_id} not found; nothing to deregister")
|
|
271
|
+
return False
|
|
272
|
+
snapshots = [m["Ebs"]["SnapshotId"] for m in images[0].get("BlockDeviceMappings", [])
|
|
273
|
+
if m.get("Ebs", {}).get("SnapshotId")]
|
|
274
|
+
ec2.deregister_image(ImageId=build_id)
|
|
275
|
+
for snap in snapshots:
|
|
276
|
+
ec2.delete_snapshot(SnapshotId=snap)
|
|
277
|
+
log.info(f"AMI {build_id} deregistered; snapshots deleted: {snapshots or 'none'}")
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
def verify_instance(self, instance_name: str, expected_build: str | None = None,
|
|
281
|
+
expect_mounts: int = 0, timeout: int = 600,
|
|
282
|
+
mount_points: list[str] | None = None) -> dict[str, Any]:
|
|
283
|
+
"""Over SSM: the launch parameters' completion marker
|
|
284
|
+
(/var/lib/csis/launch-applied, written as the user-data script's
|
|
285
|
+
last act), the mounts under /mnt, and the AMI the instance runs.
|
|
286
|
+
``mount_points`` is accepted for the contract; the count over SSM is
|
|
287
|
+
this runtime's reading (hygiene XII item 22 was GCE's console)."""
|
|
288
|
+
import time # noqa: PLC0415
|
|
289
|
+
checks: list[dict[str, Any]] = []
|
|
290
|
+
deadline = time.time() + timeout
|
|
291
|
+
script = ("test -f /var/lib/csis/launch-applied && echo LAUNCH_APPLIED; "
|
|
292
|
+
"findmnt -rn -o TARGET | grep -c '^/mnt/' || true")
|
|
293
|
+
out, rc = "", 1
|
|
294
|
+
while True:
|
|
295
|
+
try:
|
|
296
|
+
rc, out = self.run_session_command(instance_name, script, timeout=120)
|
|
297
|
+
except Exception as e: # noqa: BLE001 - SSM not up yet
|
|
298
|
+
rc, out = 1, str(e)
|
|
299
|
+
if rc == 0 and "LAUNCH_APPLIED" in out:
|
|
300
|
+
break
|
|
301
|
+
if time.time() >= deadline:
|
|
302
|
+
break
|
|
303
|
+
time.sleep(15)
|
|
304
|
+
applied = rc == 0 and "LAUNCH_APPLIED" in out
|
|
305
|
+
checks.append({"name": "startup scripts", "ok": applied,
|
|
306
|
+
"detail": "launch parameters applied" if applied else f"no completion marker within {timeout}s"})
|
|
307
|
+
booted = self.query_instance_boot_image(instance_name)
|
|
308
|
+
if expected_build:
|
|
309
|
+
checks.append({"name": "booted image", "ok": booted == expected_build,
|
|
310
|
+
"detail": f"booted {booted}, expected {expected_build}"})
|
|
311
|
+
else:
|
|
312
|
+
checks.append({"name": "booted image", "ok": booted is not None, "detail": f"booted {booted}"})
|
|
313
|
+
if expect_mounts:
|
|
314
|
+
try:
|
|
315
|
+
mounted = int([ln for ln in out.splitlines() if ln.strip().isdigit()][-1])
|
|
316
|
+
except (IndexError, ValueError):
|
|
317
|
+
mounted = 0
|
|
318
|
+
checks.append({"name": "data disks mounted", "ok": mounted >= expect_mounts,
|
|
319
|
+
"detail": f"{mounted} mount(s) under /mnt, {expect_mounts} declared"})
|
|
320
|
+
return {"ok": all(c["ok"] for c in checks), "checks": checks, "evidence": out.splitlines()[-20:]}
|
|
321
|
+
|
|
322
|
+
def running_instance_id(self, instance_name: str) -> str | None:
|
|
323
|
+
"""The id of the RUNNING instance whose Name tag is ``instance_name``."""
|
|
324
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
325
|
+
res = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [instance_name]},
|
|
326
|
+
{"Name": "instance-state-name", "Values": ["running"]}])
|
|
327
|
+
ids = [i["InstanceId"] for i in self._ours([i for r in res.get("Reservations", []) for i in r.get("Instances", [])])]
|
|
328
|
+
return ids[0] if ids else None
|
|
329
|
+
|
|
330
|
+
def ssh_proxy_command(self, instance_name: str) -> list[str] | None:
|
|
331
|
+
"""SSH over Session Manager (``AWS-StartSSHSession``): the instance's
|
|
332
|
+
SSM agent holds an outbound connection, so a machine in a private
|
|
333
|
+
subnet with no public address is reached with no inbound rule. The
|
|
334
|
+
path packer bakes through in this account; the caller needs
|
|
335
|
+
``ssm:StartSession`` on the instance and the document, and the
|
|
336
|
+
session-manager plugin beside the aws CLI."""
|
|
337
|
+
instance_id = self.running_instance_id(instance_name)
|
|
338
|
+
if instance_id is None:
|
|
339
|
+
return None
|
|
340
|
+
cmd = ["aws", "ssm", "start-session", "--target", instance_id, "--document-name", "AWS-StartSSHSession",
|
|
341
|
+
"--parameters", "portNumber=%p", "--region", str(self.model.get_region())]
|
|
342
|
+
profile = self.model.get_credentials().get("profile_name")
|
|
343
|
+
return cmd + (["--profile", str(profile)] if profile else [])
|
|
344
|
+
|
|
345
|
+
def run_session_command(self, instance_name: str, script: str, timeout: int = 300) -> tuple[int, str]:
|
|
346
|
+
"""SSM ``AWS-RunShellScript`` on the instance named by its Name tag
|
|
347
|
+
(the session mechanism instances are reached by, stage 1)."""
|
|
348
|
+
import time # noqa: PLC0415
|
|
349
|
+
cfg = self.model.self_to_aws_client_config()
|
|
350
|
+
ec2 = aws_utils.ec2_client(cfg)
|
|
351
|
+
res = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [instance_name]},
|
|
352
|
+
{"Name": "instance-state-name", "Values": ["running"]}])
|
|
353
|
+
ids = [i["InstanceId"] for i in self._ours([i for r in res.get("Reservations", []) for i in r.get("Instances", [])])]
|
|
354
|
+
if not ids:
|
|
355
|
+
# stage 57: say WHICH failure this is. A machine the operator
|
|
356
|
+
# switched off is not a machine that was never there, and
|
|
357
|
+
# "no running instance" read as the latter for both.
|
|
358
|
+
state = self.query_instance_power_state(instance_name)
|
|
359
|
+
raise RuntimeError(f"AWS runtime {self.get_name()}: cannot run a command on "
|
|
360
|
+
f"{instance_name!r}: it is {power_state.describe(state)}")
|
|
361
|
+
ssm = aws_utils.aws_client("ssm", cfg)
|
|
362
|
+
sent = ssm.send_command(InstanceIds=ids[:1], DocumentName="AWS-RunShellScript",
|
|
363
|
+
Parameters={"commands": script.splitlines()}, TimeoutSeconds=timeout)
|
|
364
|
+
command_id = sent["Command"]["CommandId"]
|
|
365
|
+
deadline = time.time() + timeout
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
inv = ssm.get_command_invocation(CommandId=command_id, InstanceId=ids[0])
|
|
369
|
+
except Exception as e: # noqa: BLE001
|
|
370
|
+
# the invocation record appears a moment AFTER send_command
|
|
371
|
+
# returns (found live, ledger 72: the unmount had already run
|
|
372
|
+
# on the instance when this raised, so no receipt was written)
|
|
373
|
+
code = str(getattr(e, "response", {}).get("Error", {}).get("Code", "")) if hasattr(e, "response") else ""
|
|
374
|
+
if code == "InvocationDoesNotExist" and time.time() < deadline:
|
|
375
|
+
time.sleep(2)
|
|
376
|
+
continue
|
|
377
|
+
raise
|
|
378
|
+
status = inv.get("Status")
|
|
379
|
+
if status in ("Success", "Failed", "TimedOut", "Cancelled"):
|
|
380
|
+
out = (inv.get("StandardOutputContent") or "") + (inv.get("StandardErrorContent") or "")
|
|
381
|
+
return (0 if status == "Success" else 1), out
|
|
382
|
+
if time.time() > deadline:
|
|
383
|
+
return 1, f"ssm command {command_id} still {status} after {timeout}s"
|
|
384
|
+
time.sleep(5)
|
|
385
|
+
|
|
386
|
+
def retag_image(self, image_id: str, tags: dict[str, str]) -> bool:
|
|
387
|
+
"""``create-tags`` on one of our own AMIs (zero-drift-report)."""
|
|
388
|
+
ec2 = aws_utils.ec2_client(self.model.self_to_aws_client_config())
|
|
389
|
+
ec2.create_tags(Resources=[image_id],
|
|
390
|
+
Tags=[{"Key": k, "Value": str(v)} for k, v in sorted(tags.items())])
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------- packer source hooks
|
|
394
|
+
def packer_source_type(self) -> str:
|
|
395
|
+
from .aws_packer_source import AMAZON_EBS
|
|
396
|
+
return AMAZON_EBS
|
|
397
|
+
|
|
398
|
+
def packer_source_blocks(self, image: Any, *, runtime: str, source_type: str, subconfig: Any,
|
|
399
|
+
self_subconfig: Any, psi: Any, tags: dict[str, str],
|
|
400
|
+
final_name: str, pinned: str | None) -> list[str]:
|
|
401
|
+
from .aws_packer_source import amazon_ebs_source
|
|
402
|
+
return amazon_ebs_source(self.model, image, runtime=runtime, source_type=source_type,
|
|
403
|
+
subconfig=subconfig, self_subconfig=self_subconfig, psi=psi,
|
|
404
|
+
tags=tags, final_name=final_name, pinned=pinned)
|
|
405
|
+
|
|
406
|
+
def generate_items_during(self, phase: ExecutionLifecyclePhase) -> AssetSet:
|
|
407
|
+
return super().generate_items_during(phase)
|
|
408
|
+
|
|
409
|
+
# ------------------------------------------------------------ V2 (N9)
|
|
410
|
+
SSM = "ssm"
|
|
411
|
+
|
|
412
|
+
def session_mechanism(self) -> str | None:
|
|
413
|
+
mech = getattr(self.model, "session_mechanism", None)
|
|
414
|
+
if not mech:
|
|
415
|
+
return None
|
|
416
|
+
mech = str(mech).strip().lower()
|
|
417
|
+
if mech != self.SSM:
|
|
418
|
+
raise ValueError(f"AWS runtime {self.get_name()}: unknown session mechanism {mech!r} "
|
|
419
|
+
f"(supported: {self.SSM})")
|
|
420
|
+
return mech
|
|
421
|
+
|
|
422
|
+
def session_agent_commands(self, os_family: str | None = None) -> list[str]:
|
|
423
|
+
if self.session_mechanism() != self.SSM:
|
|
424
|
+
return []
|
|
425
|
+
fam = (os_family or "").lower()
|
|
426
|
+
region = self.model.get_region()
|
|
427
|
+
if fam in ("debian", "ubuntu"):
|
|
428
|
+
# Guarded: the SSM-communicator bootstrap has usually installed the
|
|
429
|
+
# agent already (and owns /tmp/ssm.deb as root -- found live:
|
|
430
|
+
# curl(23) on re-download); unique filename, root-owned download.
|
|
431
|
+
install = ["command -v amazon-ssm-agent >/dev/null 2>&1 || snap list amazon-ssm-agent >/dev/null 2>&1 || "
|
|
432
|
+
f"(sudo curl -fsSL https://s3.{region}.amazonaws.com/amazon-ssm-{region}/latest/debian_amd64/amazon-ssm-agent.deb -o /tmp/ssm-agent-bake.deb && sudo dpkg -i /tmp/ssm-agent-bake.deb)"]
|
|
433
|
+
else:
|
|
434
|
+
install = [f"sudo yum install -y https://s3.{region}.amazonaws.com/amazon-ssm-{region}/latest/linux_amd64/amazon-ssm-agent.rpm || sudo yum install -y amazon-ssm-agent"]
|
|
435
|
+
return [f"# session mechanism 'ssm' ({self.get_name()}): SSM agent for debug sessions"] + install + [
|
|
436
|
+
"sudo systemctl enable amazon-ssm-agent"]
|
|
437
|
+
|
|
438
|
+
#: stage 86: how long a bake waits for Systems Manager to leave a new
|
|
439
|
+
#: machine alone -- quiet for QUIET checks in a row, STEP seconds apart;
|
|
440
|
+
#: never judged before the machine is MIN_AGE seconds old (the
|
|
441
|
+
#: associations start about forty seconds after launch); LIMIT at most.
|
|
442
|
+
SETTLE_QUIET_CHECKS = 3
|
|
443
|
+
SETTLE_STEP_SECONDS = 10
|
|
444
|
+
SETTLE_MIN_AGE_SECONDS = 90
|
|
445
|
+
SETTLE_LIMIT_SECONDS = 600
|
|
446
|
+
|
|
447
|
+
def bake_settle_commands(self, os_family: str | None = None) -> list[str]:
|
|
448
|
+
"""An account's Systems Manager may hold associations that target
|
|
449
|
+
EVERY instance (Quick Setup's host management: the agent's update,
|
|
450
|
+
a patch scan, inventory). They run on a build machine the moment its
|
|
451
|
+
agent registers, as documents under ``ssm-document-worker`` and, for
|
|
452
|
+
the agent's own update, a detached updater -- and they use the
|
|
453
|
+
package database while the bake does (found walking the daily
|
|
454
|
+
driver, stage 65, finding F29). The brackets keep the search from
|
|
455
|
+
finding itself in the process list."""
|
|
456
|
+
if self.session_mechanism() != self.SSM:
|
|
457
|
+
return []
|
|
458
|
+
quiet, step = self.SETTLE_QUIET_CHECKS, self.SETTLE_STEP_SECONDS
|
|
459
|
+
age, limit = self.SETTLE_MIN_AGE_SECONDS, self.SETTLE_LIMIT_SECONDS
|
|
460
|
+
return [
|
|
461
|
+
f"# session mechanism 'ssm' ({self.get_name()}): Systems Manager may act on every new machine (agent "
|
|
462
|
+
f"updates, patch scans, inventory); wait until it has been quiet for {quiet * step}s, {limit}s at most",
|
|
463
|
+
"quiet=0; waited=0",
|
|
464
|
+
f'while [ "$quiet" -lt {quiet} ] && [ "$waited" -lt {limit} ]; do '
|
|
465
|
+
f'if [ "$(cut -d. -f1 /proc/uptime)" -lt {age} ] || sudo grep -Eaqs '
|
|
466
|
+
"'ssm-document-work[e]r|amazon-ssm-agent-updat[e]r' /proc/[0-9]*/cmdline; "
|
|
467
|
+
f"then quiet=0; else quiet=$((quiet + 1)); fi; sleep {step}; waited=$((waited + {step})); done",
|
|
468
|
+
f'if [ "$quiet" -lt {quiet} ]; then echo "csis: Systems Manager was still acting on this machine after '
|
|
469
|
+
'$waited seconds; going on (a package step waits out a held database)"; '
|
|
470
|
+
'else echo "csis: Systems Manager is quiet on this machine (waited $waited seconds)"; fi',
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
def session_verify_commands(self, os_family: str | None = None) -> list[str]:
|
|
474
|
+
if self.session_mechanism() != self.SSM:
|
|
475
|
+
return []
|
|
476
|
+
return ["# verify: SSM agent baked",
|
|
477
|
+
"command -v amazon-ssm-agent >/dev/null 2>&1 || test -x /usr/bin/amazon-ssm-agent || snap list amazon-ssm-agent >/dev/null 2>&1",
|
|
478
|
+
"systemctl is-enabled amazon-ssm-agent >/dev/null 2>&1 || systemctl is-enabled snap.amazon-ssm-agent.amazon-ssm-agent.service >/dev/null 2>&1"]
|
|
479
|
+
|
|
480
|
+
def release_commands(self, build_id: str, tags: dict[str, str]):
|
|
481
|
+
"""Mark a released AMI in the cloud: tag it (reviewable, gated by
|
|
482
|
+
config.apply_release)."""
|
|
483
|
+
from cs_image_system.base.models.executable import ExecutableModel
|
|
484
|
+
args = ["ec2", "create-tags", "--region", str(self.model.get_region()), "--resources", build_id, "--tags"]
|
|
485
|
+
args += [f"Key={k},Value={v}" for k, v in sorted(tags.items())]
|
|
486
|
+
creds = self.model.get_credentials() # stage 17
|
|
487
|
+
if creds.get("profile_name"):
|
|
488
|
+
args += ["--profile", str(creds["profile_name"])]
|
|
489
|
+
e = ExecutableModel(name="aws", type_="executable", binary="aws")
|
|
490
|
+
e.args = args
|
|
491
|
+
return [e]
|
|
492
|
+
|
|
493
|
+
def default_bake_user(self, family: str) -> str | None:
|
|
494
|
+
"""The vendor AMI's default user: the only account whose
|
|
495
|
+
authorized_keys receives packer's temporary key (found live: a
|
|
496
|
+
Debian-derived image baked as 'ec2-user' never authenticates)."""
|
|
497
|
+
return {"debian": "admin", "ubuntu": "ubuntu"}.get((family or "").lower(), "ec2-user")
|
|
498
|
+
|
|
499
|
+
def session_instance_profile(self) -> str | None:
|
|
500
|
+
if self.session_mechanism() != self.SSM:
|
|
501
|
+
return None
|
|
502
|
+
return getattr(self.model, "session_instance_profile", None) or None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class AwsCLIVersionChecker(AbstractVersionChecker):
|
|
506
|
+
"""Version checker for AWS."""
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def csis_name(cls) -> str:
|
|
510
|
+
return AWS_CLI
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@classmethod
|
|
514
|
+
def csis_classifier(cls) -> VCT:
|
|
515
|
+
return VCT.VERSION_CHECKER
|
|
516
|
+
|
|
517
|
+
def get_regex(self) -> str:
|
|
518
|
+
return r"aws-cli\/([\d\.]+)\s"
|