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,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"