cs-image-system-tf-ebs-instance-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,677 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import json
6
+ import logging
7
+ import subprocess
8
+ from typing import Any, TypeVar
9
+
10
+
11
+ from cs_image_system.base import utils
12
+ from cs_image_system.base.constants import OOPS_DEFAULTS
13
+ from cs_image_system.base.utils import HclRaw, hcl_expr, render_module_call
14
+ from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
15
+ from cs_image_system.base.basic.asset import AssetSet
16
+ from cs_image_system.base.basic.builder_base_instance import InstanceBuilderBase
17
+ from cs_image_system.base.capabilities import group_builder_of
18
+ from cs_image_system.base.launch_params import (
19
+ ENROLLMENT_TOKEN_VARIABLE,
20
+ compute_launch_params,
21
+ user_data_template,
22
+ )
23
+ from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
24
+ from cs_image_system.base.models.executable import ExecutableModel
25
+ from cs_image_system.base.models.executable_adds import CFExecutables
26
+ from cs_image_system.base.models.instance import Instance
27
+ from cs_image_system.hashicorp_utils.collector import TerraformCollector, TerraformVariable
28
+ from cs_image_system.hashicorp_utils.roots import TerraformRootMixin
29
+
30
+ from .tf_instance_models import TofuInstanceBuilderModel, TOFU
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+
35
+ def _group_tags_line(ctx: Any) -> str:
36
+ """The security group's tags: its name, and the configuration it belongs
37
+ to (stage 82's `csis_config`, hygiene XII item 31), so that in a shared
38
+ account the state is not the only thing that says whose it is."""
39
+ from cs_image_system.base.lineage import CONFIG_TAG, configuration_tag
40
+ tag = configuration_tag(ctx)
41
+ if tag:
42
+ return f' tags = {{ Name = "csis-instances", {CONFIG_TAG} = "{tag}" }}'
43
+ return ' tags = { Name = "csis-instances" }'
44
+
45
+
46
+ def _instances_vpc(rtb: Any) -> str | None:
47
+ """The runtime's VPC id, when concretely configured."""
48
+ networking = getattr(getattr(rtb, "model", None), "networking", None)
49
+ vpc = getattr(networking, "network", None) if networking else None
50
+ return vpc if vpc and vpc not in OOPS_DEFAULTS else None
51
+
52
+ Q = TypeVar("Q", bound=TofuInstanceBuilderModel)
53
+ class TofuInstanceBuilder(InstanceBuilderBase[Q], TerraformRootMixin):
54
+ """OpenTofu IaC provider implementation.
55
+
56
+ Generates one ``module`` call per attached Instance against
57
+ tfmodules/aws_instance. The AMI comes from the instance's image's
58
+ ProviderSpecificImage: a concrete id when resolved, a most-recent
59
+ name-pattern query when deferred (the image is built by a prior phase).
60
+
61
+ V2 (N16/N26): each instance's launch parameters -- storage mounts and
62
+ the enrollment trigger -- are generated as a ``templatefile`` user-data
63
+ script whose runtime values (gids, filesystem ids, the token) enter by
64
+ terraform reference only; EBS volumes attach through the module; the
65
+ session instance profile is wired when the runtime offers one.
66
+ """
67
+
68
+ def __init__(self, *args, **kwargs):
69
+ super().__init__(*args, **kwargs)
70
+ self._instances: list[Instance] = []
71
+
72
+ @classmethod
73
+ def csis_name(cls) -> str:
74
+ return TOFU
75
+
76
+ def add_instance(self, instance: Instance) -> None:
77
+ if instance not in self._instances:
78
+ self._instances.append(instance)
79
+
80
+ def _runtime_builder(self):
81
+ ctx = self._get_context()
82
+ return ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
83
+
84
+ # ------------------------------------------------------------ helpers
85
+ def _identity_workspace_for(self, group: str | None) -> str | None:
86
+ if not group:
87
+ return None
88
+ gb = group_builder_of(self._get_context(), group)
89
+ # stage 75 step 3: the group builder says where its gids live, if anywhere
90
+ return gb.gid_workspace() if gb is not None else None
91
+
92
+ def _gid_expression_for(self, group: str | None) -> str | None:
93
+ if not group:
94
+ return None
95
+ gb = group_builder_of(self._get_context(), group)
96
+ return gb.gid_expression(group) if gb is not None else None
97
+
98
+ def _storage_builder_name(self, storage_name: str) -> str | None:
99
+ for s in self._get_context().storages:
100
+ if s.get_name() == storage_name:
101
+ return s.type_
102
+ return None
103
+
104
+ @staticmethod
105
+ def _user_data_template_name(label: str) -> str:
106
+ return f"user-data-{label}.sh.tftpl"
107
+
108
+ def generate_items_before(self, phase: ExecutionLifecyclePhase) -> AssetSet:
109
+ items = AssetSet()
110
+ if phase != ExecutionLifecyclePhase.INSTANCE_GENERATION or not self._root_has_work():
111
+ return items
112
+ rpath = self.get_path_for_phase(phase, suffix=".tf")
113
+ items = items.with_default_path(rpath)
114
+ items.add(f"# Terraform configuration for instances generated by "
115
+ f"{self.__class__.__name__} ({self.name})")
116
+ col = TerraformCollector()
117
+ ws = self.name
118
+ # Provider requirements come from configuration (required_plugins);
119
+ # fall back to a bare hashicorp/aws requirement when none declared.
120
+ if self.model.required_plugins:
121
+ for plugin in self.model.required_plugins:
122
+ col.require_provider(ws, plugin.name,
123
+ source=getattr(plugin, "source", None),
124
+ version=getattr(plugin, "version", None))
125
+ else:
126
+ col.require_provider(ws, "aws", source="hashicorp/aws")
127
+ rtb = self._runtime_builder()
128
+ get_region = getattr(rtb.model, "get_region", None) if rtb else None
129
+ region = get_region() if callable(get_region) else None
130
+ if region:
131
+ config = {"region": region}
132
+ creds = rtb.model.get_credentials() if rtb else None
133
+ profile = (creds or {}).get("profile_name")
134
+ if profile:
135
+ config["profile"] = profile
136
+ col.configure_provider(ws, "aws", config)
137
+ # One overridable AMI variable per instance: finalization writes the
138
+ # concrete ids (from packer manifests) into an .auto.tfvars once the
139
+ # builds have run; until then the module falls back to its name-pattern
140
+ # lookup.
141
+ for instance in self._instances:
142
+ label = utils.super_safe_name(instance.get_name())
143
+ col.declare_variable(ws, TerraformVariable(
144
+ name=self._ami_var(label), type="string", default=""))
145
+ self._declare_ephemeral_variable(col, ws)
146
+ # The enrollment trigger (N26) is supplied at apply time and never
147
+ # recorded anywhere: a sensitive variable with an empty default.
148
+ col.declare_variable(ws, TerraformVariable(
149
+ name=ENROLLMENT_TOKEN_VARIABLE, type="string", default="", sensitive=True,
150
+ description="Identity enrollment token handed to instances at launch (TF_VAR_...); "
151
+ "empty = no enrollment"))
152
+ # State wiring (emitted only when use_state_backends is enabled):
153
+ # this workspace's own backend, plus a remote-state datasource per
154
+ # storage workspace so instances can bind to storage outputs, and per
155
+ # identity workspace so gids flow by reference (N7).
156
+ col.bind_workspace(ws, self.model.state_configuration, self.runtime_state_configuration()) # stage 46: own, runtime's, default
157
+ ctx = self._get_context()
158
+ for sb_name, sb in ctx.storage_builders.items():
159
+ if getattr(sb.model, "_storages", None):
160
+ col.reference_remote_state(ws, producer_workspace=sb_name)
161
+ identity_ws: set[str] = set()
162
+ for instance in self._instances:
163
+ image = ctx.images_map.get(str(instance.image)) if instance.image else None
164
+ iws = self._identity_workspace_for(getattr(image, "group", None))
165
+ if iws:
166
+ identity_ws.add(iws)
167
+ for iws in sorted(identity_ws):
168
+ col.reference_remote_state(ws, producer_workspace=iws)
169
+ items.add_list(col.generate_terraform_block(ws))
170
+ items.add_list(col.generate_provider_blocks(ws))
171
+ variable_lines = col.generate_variable_blocks(ws)
172
+ if variable_lines:
173
+ items.add_list(variable_lines)
174
+ remote_lines = col.generate_remote_state_datasources(ws)
175
+ if remote_lines:
176
+ items.add_list(remote_lines)
177
+ backend_lines = col.generate_backend_config(ws)
178
+ if backend_lines:
179
+ items.add_list(self._backend_config_path(phase), backend_lines)
180
+ vpc = _instances_vpc(rtb)
181
+ if vpc and self._instances: # hygiene XII item 31: no instances, no group of their own
182
+ # Instances wear their own security group (found live: the VPC
183
+ # default group blocks the Okta gateway's inbound SSH relay, so
184
+ # sft ssh to the advertised private address hangs). SSH ingress
185
+ # references only the configured source groups (the gateway) --
186
+ # never the shared networks' CIDR space (policy).
187
+ networking = getattr(rtb.model, "networking", None) if rtb else None
188
+ ssh_sgs = list(getattr(networking, "ssh_ingress_security_group_ids", None) or [])
189
+ if ssh_sgs:
190
+ src = (" security_groups = ["
191
+ + ", ".join(f'"{s}"' for s in ssh_sgs) + "]")
192
+ else:
193
+ # no relay configured: intra-VPC private space only
194
+ src = " cidr_blocks = [data.aws_vpc.csis_instances.cidr_block]"
195
+ # finding 45: these root-level blocks must bind the ALIASED
196
+ # provider explicitly -- the collector aliases every configured
197
+ # provider (aggregation-readiness assumes resources live in
198
+ # modules), so an unqualified block falls to the DEFAULT aws
199
+ # provider, which has no configuration and only works when the
200
+ # operator's shell happens to carry ambient AWS credentials
201
+ provider_line = f" provider = aws.{utils.super_safe_name(ws)}"
202
+ items.add_list([
203
+ "",
204
+ 'data "aws_vpc" "csis_instances" {',
205
+ provider_line,
206
+ f' id = "{vpc}"',
207
+ "}",
208
+ "",
209
+ 'resource "aws_security_group" "csis_instances" {',
210
+ provider_line,
211
+ ' name_prefix = "csis-instances-"',
212
+ ' description = "cs-image-system instances: SSH from the session relay; all egress"',
213
+ " vpc_id = data.aws_vpc.csis_instances.id",
214
+ " ingress {",
215
+ " from_port = 22",
216
+ " to_port = 22",
217
+ ' protocol = "tcp"',
218
+ src,
219
+ " }",
220
+ " egress {",
221
+ " from_port = 0",
222
+ " to_port = 0",
223
+ ' protocol = "-1"',
224
+ ' cidr_blocks = ["0.0.0.0/0"]',
225
+ " }",
226
+ _group_tags_line(self._get_context()),
227
+ " # replacements must create the successor first: instances",
228
+ " # wear this group, so a delete-first plan hits",
229
+ " # DependencyViolation (found live)",
230
+ " lifecycle { create_before_destroy = true }",
231
+ "}",
232
+ ])
233
+ return items
234
+
235
+ @staticmethod
236
+ def _ami_var(label: str) -> str:
237
+ return f"{label}_ami_id"
238
+
239
+ def generate_items_during(self, phase: ExecutionLifecyclePhase) -> AssetSet:
240
+ items = AssetSet()
241
+ if phase != ExecutionLifecyclePhase.INSTANCE_GENERATION or not self._root_has_work():
242
+ return items # ledger 70: outside --only-runtime, nothing
243
+ ctx = self._get_context()
244
+ rtb = self._runtime_builder()
245
+ src = utils.module_source("aws_instance",
246
+ self.get_path_for_phase(phase, suffix=".tf").parent)
247
+ bindings = TerraformCollector().provider_bindings(self.name)
248
+ for instance in self._instances:
249
+ label = utils.super_safe_name(instance.get_name())
250
+ rpath = self.get_path_for_phase(phase, f"instance-{label}", suffix=".tf")
251
+ params = compute_launch_params(ctx, instance)
252
+ items.add(rpath,
253
+ f"# Terraform module call for instance {instance.get_name()} "
254
+ f"generated by {self.__class__.__name__} ({self.name})")
255
+ if params.get("group"):
256
+ items.add(rpath, f"# owning group: {params['group']} (via image {instance.image}); "
257
+ f"launch parameters recorded in meta-state/launch-params.yaml (N26)")
258
+ items.add_list(rpath, render_module_call(
259
+ f"instance_{label}", src, self._with_ephemeral_count(instance, self._module_args(ctx, rtb, instance, params)),
260
+ providers=bindings))
261
+ # The user-data template lives beside the root; runtime values are
262
+ # passed in by reference from the templatefile() call.
263
+ tpath = rpath.parent / self._user_data_template_name(label)
264
+ items.add(tpath, user_data_template(params))
265
+ return items
266
+
267
+ def _module_args(self, ctx, rtb, instance: Instance, params: dict[str, Any] | None = None) -> dict[str, Any]:
268
+ label = utils.super_safe_name(instance.get_name())
269
+ args: dict[str, Any] = {"name": instance.get_name()}
270
+ # Pinned build first (DESIGN §3F5): once bound, an instance keeps its
271
+ # build until an explicit upgrade moves the pin; a rebuild of its
272
+ # image's series never touches it.
273
+ from cs_image_system.base.lineage import pinned_instance_build
274
+ pinned = pinned_instance_build(ctx, instance) if hasattr(ctx, "meta_state") else None
275
+ # AMI via the image's provider-specific image
276
+ psi = (ctx.get_provider_specific_image(instance.image, rtb.get_name())
277
+ if rtb and instance.image else None)
278
+ if pinned:
279
+ args["ami_id"] = pinned
280
+ elif psi is not None and psi.is_resolved():
281
+ args["ami_id"] = psi.identifier
282
+ elif psi is not None:
283
+ # The image is built later (deferred PSI): reference the per-instance
284
+ # variable (filled in by finalization from the packer manifest) with
285
+ # the most-recent name-pattern lookup as fallback.
286
+ args["ami_id"] = f"var.{self._ami_var(label)}"
287
+ args["ami_name_pattern"] = f"{psi.deferred_name_pattern}*"
288
+ else:
289
+ log.warning(f"No provider-specific image for image {instance.image!r} of "
290
+ f"instance {instance.get_name()}; the module call will not resolve an AMI")
291
+ if rtb:
292
+ mt = getattr(instance, "machine_type", None)
293
+ args["instance_type"] = mt if mt else rtb.get_default_machine_type()
294
+ # No public IP: the VPC has no internet gateway, so a public
295
+ # address is unroutable and poisons sftd's advertised endpoint.
296
+ args["associate_public_ip_address"] = False
297
+ if _instances_vpc(rtb):
298
+ # our own group, plus any existing groups the runtime says
299
+ # instances must wear (e.g. OKTA-GATEWAY, whose membership
300
+ # the session relay requires on the target)
301
+ networking = getattr(rtb.model, "networking", None)
302
+ addl = list(getattr(networking, "addl_security_groups", None) or [])
303
+ args["vpc_security_group_ids"] = [
304
+ HclRaw("aws_security_group.csis_instances.id"), *addl]
305
+ networking = getattr(rtb.model, "networking", None)
306
+ subnet = getattr(networking, "default_subnet_id", None) if networking else None
307
+ if subnet:
308
+ args["subnet_id"] = subnet
309
+ profile_fn = getattr(rtb, "session_instance_profile", None)
310
+ profile = profile_fn() if callable(profile_fn) else None
311
+ if profile:
312
+ args["iam_instance_profile"] = profile
313
+ from cs_image_system.base.lineage import config_tags
314
+ tags = {**(instance.get_tags() or {}), **config_tags(ctx)} # stage 93: the machine carries the id
315
+ if tags:
316
+ args["tags"] = dict(tags)
317
+ # Launch parameters (N26) are added only when the caller computed them
318
+ # (generate_items_during); the AMI wiring above stands on its own.
319
+ if params is not None:
320
+ args.update(self._launch_args(ctx, label, params))
321
+ return args
322
+
323
+ def _launch_args(self, ctx, label: str, params: dict[str, Any]) -> dict[str, Any]:
324
+ """user_data (templatefile over the recorded launch parameters) and
325
+ EBS attachments -- every runtime value by terraform reference."""
326
+ out: dict[str, Any] = {}
327
+ group = params.get("group")
328
+ gid = self._gid_expression_for(group)
329
+ template_vars: dict[str, Any] = {}
330
+ if gid is not None:
331
+ template_vars["group_gid"] = HclRaw(gid)
332
+ efs: dict[str, Any] = {}
333
+ ebs: dict[str, Any] = {}
334
+ for m in params.get("mounts", []):
335
+ sb = m.get("builder") or self._storage_builder_name(m["storage"])
336
+ if not sb:
337
+ continue
338
+ ref = (f"data.terraform_remote_state.{utils.super_safe_name(sb)}"
339
+ f".outputs.storage_{utils.super_safe_name(m['storage'])}")
340
+ if m["type"] == "ebs":
341
+ ebs[m["storage"]] = {"volume_id": HclRaw(f"{ref}.volume_id"),
342
+ "device_name": m["device"]}
343
+ elif m["type"] == "efs":
344
+ ap = (f'{ref}.access_point_ids["{group}"]' if group and not m.get("public_read")
345
+ else f"{ref}.public_read_access_point_id")
346
+ efs[m["storage"]] = {"file_system_id": HclRaw(f"{ref}.file_system_id"),
347
+ "access_point_id": HclRaw(ap)}
348
+ template_vars["efs"] = efs
349
+ # the template resolves Nitro device names from the volume id
350
+ template_vars["ebs"] = ebs
351
+ enrollment = params.get("enrollment") or {}
352
+ token_expr = f"var.{ENROLLMENT_TOKEN_VARIABLE}"
353
+ if enrollment and group:
354
+ # IaC-managed enrollment tokens (PLAN.md): the group's identity
355
+ # plugin supplies the credential by reference; the operator var
356
+ # stays as explicit override and as the no-plugin fallback.
357
+ gb = group_builder_of(self._get_context(), group)
358
+ ref = gb.enrollment_token_reference(group) if gb else None
359
+ if ref:
360
+ token_expr = (f'(var.{ENROLLMENT_TOKEN_VARIABLE} != "" '
361
+ f"? var.{ENROLLMENT_TOKEN_VARIABLE} : {ref})")
362
+ template_vars[ENROLLMENT_TOKEN_VARIABLE] = (
363
+ HclRaw(token_expr) if enrollment else "")
364
+ tpl = self._user_data_template_name(label)
365
+ out["user_data"] = HclRaw(f'templatefile("${{path.module}}/{tpl}", {hcl_expr(template_vars, 2)})')
366
+ if ebs:
367
+ out["ebs_volumes"] = ebs
368
+ return out
369
+
370
+ def get_commands_to_run_after(self, phase: ExecutionLifecyclePhase) -> CFExecutables:
371
+ commands: list[ExecutableModel] = []
372
+ finalize: list[ExecutableModel] = []
373
+ if phase == ExecutionLifecyclePhase.INSTANCE_GENERATION and self._root_has_work():
374
+ wd = self.get_path_for_phase(phase, suffix=".tf").parent
375
+ # plan is deliberately not run at generation time: fixture data may
376
+ # reference resources that do not exist yet (e.g. shared EFS ids).
377
+ for cmd in [["fmt"], self._init_args(phase), ["validate"]]:
378
+ e = self.get_executable_copy()
379
+ e.args = cmd
380
+ e.working_directory = wd
381
+ commands.append(e)
382
+ # Plan -> gate -> apply run at FINALIZATION, after the packer builds
383
+ # queued under IMAGE_GENERATION. The apply guard (LEDGER.md item 3) is
384
+ # re-armed here behind config.apply_instances, with the apply gate in
385
+ # front of it: only operation-driven destroys (an instance being
386
+ # replaced by an explicit upgrade, or decommissioned) may pass.
387
+ # Per-root apply scoping (stage 7): a list-valued apply_instances
388
+ # names the roots (builder name or runtime) that may apply.
389
+ rt = str(self.model.get_runtime_provider())
390
+ apply = utils.apply_enabled("instances", self.name, [rt])
391
+ unmounts, require, detach_allow = self._detach_commands(wd)
392
+ finalize.extend(self.gated_apply_commands(
393
+ phase, wd, apply=apply,
394
+ allow_destroy=(self._decommission_whitelist() + detach_allow + self._replacement_attachments()
395
+ + self._own_resources_to_let_go()),
396
+ replace=self._replacements(), apply_flag_key="instances",
397
+ apply_root=self.name, apply_root_aliases=[rt],
398
+ pre_commands=unmounts, require_unmounted=require))
399
+ finalize.extend(self._ephemeral_commands(phase, wd, apply, rt))
400
+ return CFExecutables(commands, finalize)
401
+
402
+ # ---------------------------------------------- detach + unmount (stage 10.14)
403
+ def _attachment_address(self, instance_name: str, storage: str) -> str | None:
404
+ """The terraform address whose destroy IS the detach on this runtime
405
+ (None when the attachment is an in-place attribute, as on GCE)."""
406
+ return f"{self._module_address(instance_name)}.aws_volume_attachment.this"
407
+
408
+ def _detach_commands(self, wd) -> tuple[list[ExecutableModel], list[str], list[str]]:
409
+ """``(unmount steps, gate requirements, whitelist)`` for every mount a
410
+ launched instance's declaration dropped: the runtime unmounts it on
411
+ the machine before the plan, the gate requires that receipt, and the
412
+ attachment's destroy (where it is a resource) is whitelisted as a
413
+ detach."""
414
+ from cs_image_system.base.launch_params import detachments
415
+ from cs_image_system.base.utils import system_cli_executable_with_config as system_cli_executable
416
+ ctx = self._get_context()
417
+ steps: list[ExecutableModel] = []
418
+ require: list[str] = []
419
+ allow: list[str] = []
420
+ for instance in self._instances:
421
+ for m in detachments(ctx, instance):
422
+ storage = str(m.get("storage"))
423
+ steps.append(system_cli_executable(
424
+ ["unmount", "storage", "--instance", instance.get_name(), "--storage", storage,
425
+ "--mount-point", str(m.get("mount_point")),
426
+ "--workdir", str(ctx.generation_path / wd)], wd))
427
+ require.append(f"{instance.get_name()}:{storage}")
428
+ addr = self._attachment_address(instance.get_name(), storage)
429
+ if addr and addr not in allow:
430
+ allow.append(addr)
431
+ return steps, require, allow
432
+
433
+ # ------------------------------------------------ ephemeral instances (stage 10.1)
434
+ def ephemerals(self) -> list[Instance]:
435
+ return [i for i in self._instances if getattr(i, "ephemeral", False)]
436
+
437
+ def _declare_ephemeral_variable(self, col, ws: str) -> None:
438
+ """The toggle an ephemeral module call counts on: launched with the
439
+ default (true), torn down by the same root planned with
440
+ ``-var=ephemeral_present=false``."""
441
+ if self.ephemerals():
442
+ col.declare_variable(ws, TerraformVariable(
443
+ name="ephemeral_present", type="bool", default=True,
444
+ description="false = the run's ephemeral instances are torn down (stage 10.1)"))
445
+
446
+ def _with_ephemeral_count(self, instance: Instance, args: dict[str, Any]) -> dict[str, Any]:
447
+ if getattr(instance, "ephemeral", False):
448
+ ctx = self._get_context()
449
+ rt = str(self.model.get_runtime_provider())
450
+ standing = ctx.meta_state.current_generation(instance.get_name()) is not None
451
+ if not standing and not utils.apply_enabled("instances", self.name, [rt]):
452
+ # hygiene XII item 32 (the walk's F62): between cycles an ephemeral
453
+ # machine neither stands nor is being launched by this run, and a
454
+ # root that only PLANS must not look up an image its cycle disposed
455
+ # of. Present only when it stands, or when this root may launch it.
456
+ return {"count": HclRaw("0 # ephemeral, not standing, not launched by this run"), **args}
457
+ return {"count": HclRaw("var.ephemeral_present ? 1 : 0"), **args}
458
+ return args
459
+
460
+ def _ephemeral_commands(self, phase: ExecutionLifecyclePhase, wd, apply: bool, rt: str) -> list[ExecutableModel]:
461
+ """After the launch applied: verify each ephemeral through the runtime
462
+ (a failed verdict stops the runner -- the instance stays standing
463
+ for inspection), then plan the same root with the ephemerals absent
464
+ and apply that whitelisted destroy. Enumerated like everything else
465
+ under a dry run; emitted only when this root may apply."""
466
+ ephemerals = self.ephemerals()
467
+ if not ephemerals or not apply:
468
+ return []
469
+ from cs_image_system.base.commands.verify_instance import (
470
+ ON_FAILURE_TEARDOWN, failure_policy, teardown_due)
471
+ from cs_image_system.base.utils import system_cli_executable_with_config
472
+ ctx = self._get_context()
473
+ out: list[ExecutableModel] = []
474
+ for instance in ephemerals:
475
+ name = instance.get_name()
476
+ if teardown_due(ctx, instance):
477
+ # stage 11.3: standing since a failed verification longer than
478
+ # teardown_after -- torn down by this run without re-verifying
479
+ log.info(f"Ephemeral instance {name}: teardown_after elapsed since its failed verification; "
480
+ "tearing down without re-verifying")
481
+ continue
482
+ policy, _ = failure_policy(ctx, instance)
483
+ args = ["verify", "instance", name]
484
+ if policy == ON_FAILURE_TEARDOWN:
485
+ # the verdict is recorded; the teardown proceeds; the failed
486
+ # verdict fails the run from the after-apply hook, AFTER the
487
+ # launch record is forgotten (ledger 68: a `verify assert` as
488
+ # the script's last step failed the runner before the hook ran,
489
+ # leaving a launch record for an instance that no longer existed)
490
+ args.append("--record-only")
491
+ out.append(system_cli_executable_with_config(args, wd))
492
+ out.extend(self.gated_apply_commands(
493
+ phase, wd, apply=True,
494
+ allow_destroy=[self._module_address(i.get_name()) for i in ephemerals],
495
+ apply_flag_key="instances", apply_root=self.name, apply_root_aliases=[rt],
496
+ plan_extra_args=["-var=ephemeral_present=false"]))
497
+ return out
498
+
499
+ def _module_address(self, instance_name: str) -> str:
500
+ return f"module.instance_{utils.super_safe_name(instance_name)}"
501
+
502
+ def _replacements(self) -> list[str]:
503
+ """Instances whose pin an explicit upgrade moved: forced replacement."""
504
+ ctx = self._get_context()
505
+ from cs_image_system.base.lineage import pending_instance_replacements
506
+ names = pending_instance_replacements(ctx, self._instances) # upgrades + follow (stage 9)
507
+ return [f"{self._module_address(n)}.aws_instance.this" for n in sorted(names)]
508
+
509
+ def _replacement_attachments(self) -> list[str]:
510
+ """The volume attachments of every instance this plan replaces: an
511
+ attachment binds a volume to the instance's id, so replacing the
512
+ instance replaces the attachment, and that destroy is the same
513
+ operation-driven change the `-replace` sanctioned. The gate matched
514
+ the instance and refused its attachment on the first live replacement
515
+ (2026-09-22). None on a runtime whose attachment is an in-place
516
+ attribute (GCE)."""
517
+ ctx = self._get_context()
518
+ from cs_image_system.base.lineage import pending_instance_replacements
519
+ out: list[str] = []
520
+ for n in sorted(pending_instance_replacements(ctx, self._instances)):
521
+ addr = self._attachment_address(n, "")
522
+ if addr:
523
+ out.append(addr)
524
+ return out
525
+
526
+ def _decommission_whitelist(self) -> list[str]:
527
+ """Instances recorded in meta-state (launched) but no longer declared:
528
+ their destruction is the operation-driven decommission (N19)."""
529
+ ctx = self._get_context()
530
+ ms = ctx.meta_state
531
+ declared = {i.get_name() for i in ctx.instances}
532
+ params = ms.launch_params()
533
+ pins = ms.instance_pins()
534
+ mine = str(self.model.get_runtime_provider())
535
+ out: list[str] = []
536
+ for name in sorted((set(params) | set(pins)) - declared):
537
+ # Scope to THIS runtime's root (finding 52): the recorded build's
538
+ # lineage record names the runtime it was baked on. An unknown
539
+ # runtime stays whitelisted rather than silently un-destroyable.
540
+ build_id = pins.get(name) or (params.get(name) or {}).get("build")
541
+ rec = ms.build(str(build_id)) if build_id else None
542
+ rt = str(rec.get("runtime")) if rec and rec.get("runtime") else None
543
+ if rt is None or rt == mine:
544
+ out.append(self._module_address(name))
545
+ return out
546
+
547
+ def _root_has_work(self) -> bool:
548
+ """The instance root must be emitted when it declares instances OR
549
+ when a recorded instance was decommissioned (finding 52: with the
550
+ last instance on a runtime undeclared, the root vanished entirely,
551
+ so nothing ever planned the destroy and the VM stayed orphaned in
552
+ tofu state and the cloud -- the whitelist had a name and no plan to
553
+ whitelist it in). An instance-less root is terraform + provider +
554
+ backend only; its plan is exactly the pending destroys. Under
555
+ ``--only-runtime`` another runtime's root emits nothing (ledger 70)."""
556
+ if not utils.root_in_runtime_scope(self.model.get_runtime_provider()):
557
+ return False
558
+ if self._instances or self._decommission_whitelist():
559
+ return True
560
+ # hygiene XII item 31 (the walk's F61): once every recorded instance was
561
+ # forgotten, the root vanished again and the security group it had made
562
+ # stood in the cloud and in its state with no run that would ever plan it.
563
+ # A root whose state location is recorded is emitted, empty, until its
564
+ # state holds nothing.
565
+ return self.name in self._get_context().meta_state.state_locations()
566
+
567
+ def _own_resources_to_let_go(self) -> list[str]:
568
+ """The root's own resources (not an instance's) the gate may let a plan
569
+ destroy: with no instance declared, the security group the root made
570
+ for them (hygiene XII item 31)."""
571
+ return [] if self._instances else ["aws_security_group.csis_instances"]
572
+
573
+ def pre_finalize_phase(self, phase: ExecutionLifecyclePhase) -> None:
574
+ """Before the deferred plan/apply runs, pin the AMIs this run built.
575
+
576
+ The packer builder's post-IMAGE_GENERATION hook has already upgraded the
577
+ built images' provider-specific images to resolved; write their concrete
578
+ ids into an .auto.tfvars so the instance modules use exact artifacts
579
+ instead of the most-recent name-pattern fallback.
580
+ """
581
+ if not utils.root_in_runtime_scope(self.model.get_runtime_provider()):
582
+ # stage 63: another runtime's root emitted nothing this run (ledger
583
+ # 70), so there is no tfvars to write for it (it wrote one anyway,
584
+ # and logged "No resolved provider-specific images" on every
585
+ # scoped run)
586
+ return
587
+ if phase != ExecutionLifecyclePhase.INSTANCE_GENERATION or not self._instances:
588
+ return
589
+ ctx = self._get_context()
590
+ # Stage 61 item 5: the release and retention lifecycles extend this
591
+ # phase with one deferred step of their own, so the hook fired for
592
+ # them too and wrote the file under generated/release/ and
593
+ # generated/retention/, where no instance root exists and nothing
594
+ # reads it (never staged, it lingered untracked after every release
595
+ # run). Only the lifecycle that generates instance roots gets it.
596
+ from cs_image_system.base.lifecycles import Lifecycle
597
+ current = getattr(ctx, "current_lifecycle", None) # a bare harness context has none
598
+ if current is not None and str(current.value) != Lifecycle.INSTANCE_IMAGE.value:
599
+ return
600
+ rtb = self._runtime_builder()
601
+ if rtb is None:
602
+ return
603
+ lines: list[str] = []
604
+ for instance in self._instances:
605
+ psi = (ctx.get_provider_specific_image(instance.image, rtb.get_name())
606
+ if instance.image else None)
607
+ if psi is not None and psi.is_resolved():
608
+ label = utils.super_safe_name(instance.get_name())
609
+ lines.append(f'{self._ami_var(label)} = "{psi.identifier}"')
610
+ if not lines:
611
+ log.info("No resolved provider-specific images for instances; "
612
+ "modules will use their name-pattern fallback")
613
+ return
614
+ wd = ctx.generation_path / self.get_path_for_phase(phase, suffix=".tf").parent
615
+ wd.mkdir(parents=True, exist_ok=True)
616
+ tfvars = wd / "instances.auto.tfvars"
617
+ tfvars.write_text("# AMI ids resolved from packer manifests by finalization\n"
618
+ + "\n".join(lines) + "\n")
619
+ log.info(f"Wrote resolved instance AMIs to {tfvars}")
620
+
621
+ def post_finalize_phase(self, phase: ExecutionLifecyclePhase) -> None:
622
+ """After the instance root applied for real: bind every unpinned
623
+ instance to the build it was launched from (first bind, N5)."""
624
+ if phase != ExecutionLifecyclePhase.INSTANCE_GENERATION or not self._instances:
625
+ return
626
+ if not utils.root_in_runtime_scope(self.model.get_runtime_provider()):
627
+ return # stage 63: a root out of this run's scope applied nothing to bind
628
+ if not utils.apply_enabled("instances", self.name, [str(self.model.get_runtime_provider())]):
629
+ return
630
+ ctx = self._get_context()
631
+ rtb = self._runtime_builder()
632
+ if rtb is None:
633
+ return
634
+ ms = ctx.meta_state
635
+ from cs_image_system.base.lineage import instance_follow_target
636
+ for instance in self._instances:
637
+ name = instance.get_name()
638
+ if getattr(instance, "ephemeral", False):
639
+ continue # torn down by its own sequence; nothing to bind (stage 10.1)
640
+ target = instance_follow_target(ctx, instance)
641
+ if target:
642
+ # follow (stage 9.5): the replacement applied; the pin moves now
643
+ ms.move_pin("instance", name, target[1], ctx.run_id, op="follow", pending=False)
644
+ log.info(f"Instance {name} pin moved {target[0]} -> {target[1]} (image_policy: follow)")
645
+ continue
646
+ if ms.instance_pin(name):
647
+ continue
648
+ psi = (ctx.get_provider_specific_image(instance.image, rtb.get_name())
649
+ if instance.image else None)
650
+ if psi is not None and psi.is_resolved() and psi.identifier:
651
+ ms.bind_instance(name, psi.identifier, ctx.run_id)
652
+ log.info(f"First bind: instance {name} -> build {psi.identifier} (launched this run)")
653
+ elif rtb.can_query_instance_boot_image():
654
+ # finding 49: the parent was deferred (a family lookup the
655
+ # cloud resolved at launch), so bind from REALITY -- the
656
+ # image the instance actually booted -- when lineage records
657
+ # it; a later re-bake must never move this pin silently
658
+ booted = rtb.query_instance_boot_image(name)
659
+ if booted and ms.build(str(booted)):
660
+ ms.bind_instance(name, str(booted), ctx.run_id)
661
+ log.info(f"First bind: instance {name} -> build {booted} (booted image, read from the cloud)")
662
+ elif booted:
663
+ log.warning(f"Instance {name} booted {booted}, which lineage does not record; pin left unbound")
664
+
665
+
666
+ class TofuVersionChecker(AbstractVersionChecker):
667
+ """Version checker for OpenTofu."""
668
+
669
+ @classmethod
670
+ def csis_name(cls) -> str:
671
+ return TOFU
672
+
673
+ def get_version_params(self) -> list[str]:
674
+ return ["--version", "-json"]
675
+
676
+ def get_extracted_string(self, res: subprocess.CompletedProcess[str]) -> str | None:
677
+ return json.loads(res.stdout.strip()).get("terraform_version", None)