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.
- cs_image_system/py.typed +0 -0
- cs_image_system/tf_ebs_instance_plugin/__init__.py +3 -0
- cs_image_system/tf_ebs_instance_plugin/helpers.py +8 -0
- cs_image_system/tf_ebs_instance_plugin/main.py +74 -0
- cs_image_system/tf_ebs_instance_plugin/tf_instance_builder.py +677 -0
- cs_image_system/tf_ebs_instance_plugin/tf_instance_models.py +31 -0
- cs_image_system/tf_ebs_instance_plugin/tf_storage_builder.py +833 -0
- cs_image_system/tf_ebs_instance_plugin/tf_storage_models.py +95 -0
- cs_image_system_tf_ebs_instance_plugin-0.1.1.dist-info/METADATA +1376 -0
- cs_image_system_tf_ebs_instance_plugin-0.1.1.dist-info/RECORD +13 -0
- cs_image_system_tf_ebs_instance_plugin-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_tf_ebs_instance_plugin-0.1.1.dist-info/entry_points.txt +2 -0
- cs_image_system_tf_ebs_instance_plugin-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -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)
|