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,833 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import json
6
+ import subprocess
7
+ from typing import Any, TypeVar, cast
8
+
9
+
10
+ from cs_image_system.base import utils
11
+ from cs_image_system.base.utils import HclRaw, render_module_call
12
+ from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
13
+ from cs_image_system.base.basic.asset import AssetSet
14
+ from cs_image_system.base.basic.builder_base_group import GroupBuilderBase
15
+ from cs_image_system.base.basic.builder_base_storage import (
16
+ CARDINALITY_MANY,
17
+ CARDINALITY_SINGLE,
18
+ StorageBuilderBase,
19
+ )
20
+ from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
21
+ from cs_image_system.base.models.executable import ExecutableModel
22
+ from cs_image_system.base.models.executable_adds import CFExecutables
23
+ from cs_image_system.base.models.storage import STORAGE_STATE_ACTIVE, STORAGE_STATE_ARCHIVED, STORAGE_STATE_DESTROYED, Storage
24
+ from cs_image_system.hashicorp_utils.blocks import OutputSpec, Raw, render_blocks
25
+ from cs_image_system.hashicorp_utils.collector import TerraformCollector
26
+ from cs_image_system.hashicorp_utils.roots import TerraformRootMixin
27
+
28
+ from .tf_storage_models import TF_AWS, TF_AWS_EBS, TF_AWS_EFS, TF_AWS_S3, TofuEbsStorageBuilderModel, TofuEfsStorageBuilderModel, TofuS3StorageBuilderModel, TofuStorageBuilderModel
29
+
30
+ Q = TypeVar("Q", bound=TofuStorageBuilderModel)
31
+
32
+
33
+ # Duck-typed accessors: unit tests pass minimal storage stand-ins.
34
+ def _state(storage: Any) -> str:
35
+ return getattr(storage, "state", "active")
36
+
37
+
38
+ class RecordedStorage:
39
+ """A storage known only from meta-state (its entry left the YAML, TODO
40
+ §10.11): enough of the Storage surface for a whitelist, a wipe and a
41
+ state lookup."""
42
+
43
+ def __init__(self, name: str, state: str | None, facts: dict[str, Any]) -> None:
44
+ self.name = name
45
+ self.recorded_state = state
46
+ self.state = STORAGE_STATE_DESTROYED # what the run requests
47
+ self.type_ = str(facts.get("builder") or "")
48
+ self.bucket_name = facts.get("bucket_name")
49
+ self.groups: list[str] = []
50
+ self.public_read = False
51
+ self.share_mode = "2770"
52
+ self.tags: dict[str, str] = {}
53
+ self.undeclared = True
54
+
55
+ def get_name(self) -> str:
56
+ return self.name
57
+
58
+ def __repr__(self) -> str:
59
+ return f"RecordedStorage({self.name!r}, {self.recorded_state!r})"
60
+
61
+
62
+ def _groups(storage: Any) -> list[str]:
63
+ return list(getattr(storage, "groups", []) or [])
64
+
65
+
66
+ def _public_read(storage: Any) -> bool:
67
+ return bool(getattr(storage, "public_read", False))
68
+
69
+
70
+ class TofuStorageBuilder(StorageBuilderBase[Q], TerraformRootMixin):
71
+ """OpenTofu IaC provider implementation.
72
+
73
+ Generates one ``module`` call per attached Storage item against the
74
+ corresponding tfmodules/ module, plus a shared terraform/provider file.
75
+
76
+ V2 (DESIGN §3E): group access is realized by gid REFERENCE -- the root
77
+ reads the identity workspace's ``group_gids`` output through
78
+ ``data.terraform_remote_state`` and never carries a literal gid; storages
79
+ in requested state ``destroyed`` are omitted (their destruction is the
80
+ only destroy the apply gate whitelists); every storage publishes its
81
+ module outputs for the instance-image lifecycle to bind to.
82
+ """
83
+
84
+ @classmethod
85
+ def csis_name(cls) -> str:
86
+ return TF_AWS
87
+
88
+ def module_dirname(self) -> str:
89
+ """tfmodules/ directory this builder's storages map to."""
90
+ raise NotImplementedError(f"{self.__class__.__name__} must name its terraform module")
91
+
92
+ def module_args(self, storage: Storage) -> dict[str, Any]:
93
+ """Module variables for one storage item."""
94
+ raise NotImplementedError
95
+
96
+ def _region(self) -> str | None:
97
+ ctx = self._get_context()
98
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
99
+ get_region = getattr(rtb.model, "get_region", None) if rtb else None
100
+ return str(get_region()) if callable(get_region) else None
101
+
102
+ # ------------------------------------------------------------ helpers
103
+ def live_storages(self) -> list[Storage]:
104
+ """Storages emitted as module calls: everything but tombstones -- and,
105
+ on a builder that supports archiving (stage 11.6), everything but
106
+ archived ones too: archived = the live resource is gone, its data
107
+ kept in the builder's archive (a snapshot)."""
108
+ return [s for s in self.model._storages
109
+ if _state(s) != STORAGE_STATE_DESTROYED
110
+ and not (_state(s) == STORAGE_STATE_ARCHIVED and self.supports_archive())]
111
+
112
+ def supports_archive(self) -> bool:
113
+ """Whether this builder realizes the `archived` state (snapshot +
114
+ delete, restore from the snapshot). Default: no -- `archived` is then
115
+ a validation error for its storages."""
116
+ return False
117
+
118
+ def archived_storages(self) -> list[Storage]:
119
+ return [s for s in self.model._storages if _state(s) == STORAGE_STATE_ARCHIVED]
120
+
121
+ def post_transition_actions(self, storage: Storage, from_state: str | None, to_state: str) -> list[ExecutableModel]:
122
+ """Commands the plugin runs AFTER the apply of a transition (TODO
123
+ §11.6): e.g. delete the archive snapshot once the disk was restored
124
+ from it. Default: none."""
125
+ return []
126
+
127
+ def tombstones(self) -> list[Storage]:
128
+ return [s for s in self.model._storages if _state(s) == STORAGE_STATE_DESTROYED]
129
+
130
+ def undeclared_storages(self) -> list[Any]:
131
+ """Storages this root created that are recorded (not destroyed) but no
132
+ longer declared (stage 10.11): their module call is simply absent, so
133
+ the plan destroys them; the gate whitelists that as the demise. Each
134
+ is a stand-in built from the record's facts (name, bucket)."""
135
+ ctx = self._get_context()
136
+ from cs_image_system.base.storage_state import undeclared_transitions
137
+ out: list[Any] = []
138
+ for t in undeclared_transitions(ctx):
139
+ if t.builder != self.name:
140
+ continue
141
+ facts = (ctx.meta_state.storage_states().get(t.storage) or {}).get("facts") or {}
142
+ out.append(RecordedStorage(t.storage, t.from_state, facts))
143
+ return out
144
+
145
+ def _has_work(self) -> bool:
146
+ if not utils.root_in_runtime_scope(self.model.get_runtime_provider()):
147
+ return False # ledger 70: another runtime's root
148
+ return bool(self.model._storages) or bool(self.undeclared_storages())
149
+
150
+ @staticmethod
151
+ def storage_label(storage: Storage) -> str:
152
+ return utils.super_safe_name(storage.get_name())
153
+
154
+ def _group_builder_for(self, group: str) -> GroupBuilderBase | None:
155
+ ctx = self._get_context()
156
+ for builder in ctx.group_builders.values():
157
+ if any(g.get_name() == group for g in builder.get_groups_for_builder()):
158
+ return builder
159
+ return None
160
+
161
+ def identity_workspaces(self) -> dict[str, GroupBuilderBase]:
162
+ """Identity workspaces whose gids this root consumes, by workspace name."""
163
+ out: dict[str, GroupBuilderBase] = {}
164
+ for storage in self.live_storages():
165
+ for group in _groups(storage):
166
+ gb = self._group_builder_for(group)
167
+ ws = gb.gid_workspace() if gb is not None else None # stage 75 step 3
168
+ if gb is not None and ws is not None:
169
+ out[ws] = gb
170
+ return out
171
+
172
+ def gid_reference(self, group: str) -> HclRaw:
173
+ """The group builder's ``gid_expression`` (stage 75 step 3): for an
174
+ identity root, ``data.terraform_remote_state.<identity ws>.outputs.
175
+ group_gids["<group>"]`` (N7); for a configuration-time gid, the number."""
176
+ gb = self._group_builder_for(group)
177
+ if gb is None:
178
+ raise ValueError(f"Storage builder {self.name}: group {group!r} has no identity builder")
179
+ gid = gb.gid_expression(group) # stage 75 step 3: the group builder writes it
180
+ if gid is None:
181
+ raise ValueError(f"Storage builder {self.name}: group {group!r}'s identity builder {gb.get_name()} "
182
+ f"supplies no gid")
183
+ return HclRaw(gid)
184
+
185
+ def group_subtrees(self, storage: Storage) -> dict[str, dict[str, Any]]:
186
+ """N13/N15: one root-level subtree per allowed group, gid by reference,
187
+ mode from share_mode (2770 private / 2775 read-shared)."""
188
+ return {
189
+ group: {"gid": self.gid_reference(group), "path": f"/{group}",
190
+ "permissions": getattr(storage, "share_mode", "2770")}
191
+ for group in sorted(_groups(storage))
192
+ }
193
+
194
+ def generate_items_before(self, phase: ExecutionLifecyclePhase) -> AssetSet:
195
+ items = AssetSet()
196
+ # the root exists while it declares storages OR still owns undeclared
197
+ # ones whose destroy must be planned (stage 10.11, the finding-52 shape)
198
+ if phase != ExecutionLifecyclePhase.STORAGE_GENERATION or not self._has_work():
199
+ return items
200
+ rpath = self.get_path_for_phase(phase, suffix=".tf")
201
+ items = items.with_default_path(rpath)
202
+ items.add(f"# Terraform configuration for storages generated by "
203
+ f"{self.__class__.__name__} ({self.name})")
204
+ col = TerraformCollector()
205
+ ws = self.name
206
+ if self.model.required_plugins:
207
+ for plugin in self.model.required_plugins:
208
+ col.require_provider(ws, plugin.name,
209
+ source=getattr(plugin, "source", None),
210
+ version=getattr(plugin, "version", None))
211
+ else:
212
+ col.require_provider(ws, "aws", source="hashicorp/aws")
213
+ ctx = self._get_context()
214
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
215
+ region = self._region()
216
+ if region:
217
+ config = {"region": region}
218
+ creds = rtb.model.get_credentials() if rtb else None
219
+ profile = (creds or {}).get("profile_name")
220
+ if profile:
221
+ config["profile"] = profile
222
+ col.configure_provider(ws, "aws", config)
223
+ col.bind_workspace(ws, self.model.state_configuration, self.runtime_state_configuration()) # stage 46: own, runtime's, default
224
+ # gids by reference: the identity workspace's state (applied before
225
+ # storage in meta-workflow order) is read through remote state.
226
+ for identity_ws in sorted(self.identity_workspaces()):
227
+ col.reference_remote_state(ws, producer_workspace=identity_ws)
228
+ items.add_list(col.generate_terraform_block(ws))
229
+ items.add_list(col.generate_provider_blocks(ws))
230
+ remote_lines = col.generate_remote_state_datasources(ws)
231
+ if remote_lines:
232
+ items.add_list(remote_lines)
233
+ backend_lines = col.generate_backend_config(ws)
234
+ if backend_lines:
235
+ items.add_list(self._backend_config_path(phase), backend_lines)
236
+ return items
237
+
238
+ def generate_items_during(self, phase: ExecutionLifecyclePhase) -> AssetSet:
239
+ items = AssetSet()
240
+ if phase != ExecutionLifecyclePhase.STORAGE_GENERATION or not self._has_work():
241
+ return items # ledger 70: outside --only-runtime, nothing
242
+ src = utils.module_source(self.module_dirname(),
243
+ self.get_path_for_phase(phase, suffix=".tf").parent)
244
+ bindings = TerraformCollector().provider_bindings(self.name)
245
+ for storage in self.live_storages():
246
+ label = self.storage_label(storage)
247
+ rpath = self.get_path_for_phase(phase, f"storage-{label}", suffix=".tf")
248
+ items.add(rpath,
249
+ f"# Terraform module call for storage {storage.get_name()} "
250
+ f"generated by {self.__class__.__name__} ({self.name})")
251
+ if _groups(storage):
252
+ items.add(rpath, f"# allowed groups: {', '.join(_groups(storage))} "
253
+ f"(gids by remote-state reference); public_read={str(_public_read(storage)).lower()}; "
254
+ f"requested state: {_state(storage)}")
255
+ items.add_list(rpath, render_module_call(
256
+ f"storage_{label}", src, self.module_args(storage),
257
+ providers=bindings))
258
+ # Publish the module outputs for the instance-image lifecycle.
259
+ items.add_list(rpath, render_blocks([OutputSpec(
260
+ f"storage_{label}", Raw(f"module.storage_{label}"),
261
+ description=f"Outputs of storage {storage.get_name()} for remote-state consumers")]))
262
+ for storage in self.tombstones():
263
+ label = self.storage_label(storage)
264
+ rpath = self.get_path_for_phase(phase, f"storage-{label}", suffix=".tf")
265
+ items.add(rpath, f"# Storage {storage.get_name()} is DESTROYED (tombstone): its resources are gone; "
266
+ "nothing is emitted (a re-declared name is a new generation, stage 10.12).")
267
+ if self.supports_archive():
268
+ for storage in self.archived_storages():
269
+ label = self.storage_label(storage)
270
+ rpath = self.get_path_for_phase(phase, f"storage-{label}", suffix=".tf")
271
+ items.add(rpath, f"# Storage {storage.get_name()} is ARCHIVED (stage 11.6): the live resource is "
272
+ f"gone, its data is kept in {self.archive_name(storage)}; nothing is emitted.")
273
+ return items
274
+
275
+ def archive_name(self, storage: Storage) -> str:
276
+ """The builder's archive object for a storage (a snapshot name)."""
277
+ return f"csis-{self.storage_label(storage).replace('_', '-')}-archive"
278
+
279
+ def get_commands_to_run_after(self, phase: ExecutionLifecyclePhase) -> CFExecutables:
280
+ commands: list[ExecutableModel] = []
281
+ deferred: list[ExecutableModel] = []
282
+ if phase == ExecutionLifecyclePhase.STORAGE_GENERATION and self._has_work():
283
+ wd = self.get_path_for_phase(phase, suffix=".tf").parent
284
+ # plan is deliberately not run at generation time: fixture data may
285
+ # reference resources that do not exist yet (e.g. shared EFS ids).
286
+ for cmd in [["fmt"], self._init_args(phase), ["validate"]]:
287
+ e = self.get_executable_copy()
288
+ e.args = cmd
289
+ e.working_directory = wd
290
+ commands.append(e)
291
+ allow: list[str] = []
292
+ after_apply: list[ExecutableModel] = []
293
+ ms = self._get_context().meta_state
294
+ # declared tombstones (state: destroyed) and undeclared storages
295
+ # (their entry left the YAML, stage 10.11) are both a destroy
296
+ # this run executes: plugin action first (wipe), then the
297
+ # whitelisted destroy
298
+ for storage in list(self.tombstones()) + list(self.undeclared_storages()):
299
+ cur = ms.storage_state(storage.get_name())
300
+ if cur is not None and cur != STORAGE_STATE_DESTROYED:
301
+ deferred.extend(self.transition_actions(storage, cur, STORAGE_STATE_DESTROYED))
302
+ if cur != STORAGE_STATE_ARCHIVED or not self.supports_archive():
303
+ allow.extend(self.destroy_whitelist(storage)) # an archived disk is already gone
304
+ # archived (stage 11.6): snapshot first, then the whitelisted destroy
305
+ # of the live resource; a restore (recorded archived, declared
306
+ # active) recreates it from the snapshot and deletes the snapshot
307
+ # after the apply
308
+ if self.supports_archive():
309
+ for storage in self.archived_storages():
310
+ cur = ms.storage_state(storage.get_name())
311
+ if cur == STORAGE_STATE_ACTIVE:
312
+ deferred.extend(self.transition_actions(storage, cur, STORAGE_STATE_ARCHIVED))
313
+ allow.extend(self.destroy_whitelist(storage))
314
+ for storage in self.live_storages():
315
+ if ms.storage_state(storage.get_name()) == STORAGE_STATE_ARCHIVED:
316
+ after_apply.extend(self.post_transition_actions(storage, STORAGE_STATE_ARCHIVED, STORAGE_STATE_ACTIVE))
317
+ # Per-root apply scoping (stage 7): builder name or runtime
318
+ rt = str(self.model.get_runtime_provider())
319
+ apply = utils.apply_enabled("storage", self.name, [rt])
320
+ deferred.extend(self.gated_apply_commands(
321
+ phase, wd, apply=apply, allow_destroy=allow,
322
+ apply_flag_key="storage", apply_root=self.name, apply_root_aliases=[rt]))
323
+ if apply:
324
+ deferred.extend(after_apply)
325
+ return CFExecutables(commands, deferred)
326
+
327
+ # ------------------------------------------- AWS CLI transition scripts
328
+ def _aws_cli_flags(self) -> str:
329
+ """``--region … --profile …`` for the runtime's session, as the S3
330
+ wipe passes them (credentials stay in the environment)."""
331
+ ctx = self._get_context()
332
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
333
+ getter = getattr(getattr(rtb, "model", None), "get_credentials", None) # stage 17
334
+ creds = (getter() if callable(getter) else None) or {}
335
+ profile = creds.get("profile_name") if isinstance(creds, dict) else None
336
+ flags = ""
337
+ region = self._region()
338
+ if region:
339
+ flags += f' --region "{region}"'
340
+ if profile:
341
+ flags += f' --profile "{profile}"'
342
+ return flags
343
+
344
+ def _script(self, name: str, body: str) -> ExecutableModel:
345
+ """A generated bash script beside the root, run as a deferred step (the
346
+ pd builder's shape, stage 11.6)."""
347
+ wd = self.get_path_for_phase(ExecutionLifecyclePhase.STORAGE_GENERATION, suffix=".tf").parent
348
+ target = self._get_context().generation_path / wd / name
349
+ target.parent.mkdir(parents=True, exist_ok=True)
350
+ target.write_text("#!/usr/bin/env bash\nset -uo pipefail\n" + body)
351
+ e = ExecutableModel(name="bash", type_="executable", binary="bash")
352
+ e.args = [name]
353
+ e.working_directory = wd
354
+ return e
355
+
356
+ # ------------------------------------------------------- state query
357
+ def _aws_client(self, service: str) -> Any:
358
+ from cs_image_system.aws_runtime.aws_utils import aws_client
359
+ ctx = self._get_context()
360
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
361
+ session: dict[str, Any] = {"region_name": self._region()}
362
+ cfg: Any = getattr(rtb.model, "self_to_aws_client_config", None) if rtb else None
363
+ if callable(cfg):
364
+ session = cast(dict[str, Any], cfg())
365
+ return aws_client(service, session)
366
+
367
+ def _lookup(self, storage: Storage) -> dict[str, Any] | None:
368
+ """Provider record for one storage by its ``Name`` tag, or ``None``.
369
+ Subclasses answer per service."""
370
+ raise NotImplementedError
371
+
372
+ def query_state(self) -> dict[str, dict[str, Any]]:
373
+ out: dict[str, dict[str, Any]] = {}
374
+ for storage in list(self.model._storages) + list(self.undeclared_storages()):
375
+ rec = self._lookup(storage)
376
+ out[storage.get_name()] = {"present": rec is not None, "type": self.capability_type(),
377
+ **(rec or {})}
378
+ return out
379
+
380
+ def _owned(self, found: list[dict[str, Any]], tags_of) -> tuple[list[dict[str, Any]], str]:
381
+ """Of the things found by name, those this configuration may take for
382
+ its own (stage 93: ours, or unlabelled); and the id of another
383
+ configuration that holds one of that name, for the note."""
384
+ from cs_image_system.base.lineage import belongs_to_another_configuration, owner_of
385
+ ctx = self._get_context()
386
+ ours = [f for f in found if not belongs_to_another_configuration(ctx, tags_of(f))]
387
+ elsewhere = next((owner_of(tags_of(f)) for f in found if belongs_to_another_configuration(ctx, tags_of(f))), "")
388
+ return ours, elsewhere
389
+
390
+ @staticmethod
391
+ def _elsewhere(owner: str, what: str) -> dict[str, Any]:
392
+ """The record of a thing of our name that belongs to another
393
+ configuration: absent for us, and said so (the state query makes a
394
+ note of it rather than a foreign claim)."""
395
+ return {"present": False, "elsewhere": owner, "what": what}
396
+
397
+ def destroy_whitelist(self, storage: Storage) -> list[str]:
398
+ return [f"module.storage_{self.storage_label(storage)}"]
399
+
400
+
401
+ def _tags_for(builder: Any, storage: Storage) -> dict[str, str]:
402
+ """The tags a storage's module call carries: the builder's defaults
403
+ under the item's own, and the configuration's id last (stage 93). A
404
+ stand-in without a context (the module-call tests) gets no id."""
405
+ from cs_image_system.base.lineage import config_tags
406
+ merged = dict(builder.model.variables.merged_tags(storage.tags) or {})
407
+ get_ctx: Any = getattr(builder, "_get_context", None)
408
+ if callable(get_ctx):
409
+ merged.update(config_tags(cast(Any, get_ctx())))
410
+ return merged
411
+
412
+
413
+ R = TypeVar("R", bound=TofuEbsStorageBuilderModel)
414
+ class TofuEbsStorageBuilder(TofuStorageBuilder[R]):
415
+ """OpenTofu IaC provider implementation for EBS volumes."""
416
+
417
+ @classmethod
418
+ def csis_name(cls) -> str:
419
+ return TF_AWS_EBS
420
+
421
+ @classmethod
422
+ def capability_type(cls) -> str:
423
+ return "ebs"
424
+
425
+ def is_zonal(self) -> bool:
426
+ return True # stage 52: an EBS volume lives in one zone; changing it replaces the volume
427
+
428
+ def attachment_cardinality(self) -> str:
429
+ # A volume attaches to one instance at a time (the forcing argument of N16).
430
+ return CARDINALITY_SINGLE
431
+
432
+ def base_image_prerequisites(self, os_family: str | None = None) -> list[str]:
433
+ # No tooling to bake (N4: still must be declared to be used).
434
+ return [f"# storage type 'ebs' declared: no prerequisites to bake ({self.get_name()})"]
435
+
436
+ def _lookup(self, storage: Storage) -> dict[str, Any] | None:
437
+ ec2 = self._aws_client("ec2")
438
+ if self._get_context().meta_state.storage_state(storage.get_name()) == STORAGE_STATE_ARCHIVED:
439
+ # archived (stage 15): the volume is expected absent; the archive
440
+ # snapshot -- addressed by its Name tag -- is what must exist
441
+ snaps = ec2.describe_snapshots(OwnerIds=["self"], Filters=[
442
+ {"Name": "tag:Name", "Values": [self.archive_name(storage)]}]).get("Snapshots", [])
443
+ if not snaps:
444
+ return None
445
+ snap = snaps[0]
446
+ return {"id": snap.get("SnapshotId"), "state": "archived", "size": snap.get("VolumeSize"),
447
+ "archive": snap.get("SnapshotId"), "snapshot_state": snap.get("State"),
448
+ "tags": {t["Key"]: t["Value"] for t in snap.get("Tags", []) if "Key" in t}}
449
+ vols = ec2.describe_volumes(Filters=[{"Name": "tag:Name", "Values": [storage.get_name()]}]).get("Volumes", [])
450
+ tags_of = lambda v: {t["Key"]: t["Value"] for t in v.get("Tags", []) if "Key" in t} # noqa: E731
451
+ vols, elsewhere = self._owned(vols, tags_of) # stage 93: another configuration's `data` is not ours
452
+ if not vols:
453
+ return self._elsewhere(elsewhere, "an EBS volume") if elsewhere else None
454
+ v = vols[0]
455
+ return {"id": v.get("VolumeId"), "state": v.get("State"), "size": v.get("Size"), "tags": tags_of(v)}
456
+
457
+ def module_dirname(self) -> str:
458
+ return "aws_storage_ebs"
459
+
460
+ def retag_storage(self, storage: Storage, tags: dict[str, str]) -> bool:
461
+ rec = self._lookup(storage)
462
+ if not rec or not rec.get("present", True) or not rec.get("id"):
463
+ return False
464
+ self._aws_client("ec2").create_tags(Resources=[str(rec["id"])],
465
+ Tags=[{"Key": k, "Value": str(v)} for k, v in tags.items()])
466
+ return True
467
+
468
+ # ------------------------------------------------ archived (stage 15)
469
+ def supports_archive(self) -> bool:
470
+ return True
471
+
472
+ def transition_actions(self, storage: Storage, from_state: str | None,
473
+ to_state: str) -> list[ExecutableModel]:
474
+ """``active -> archived``: snapshot the volume BEFORE terraform destroys
475
+ it (idempotent: a completed snapshot carrying the archive's Name tag is
476
+ reused); ``archived -> destroyed``: the volume is already gone, the
477
+ archive goes with the demise."""
478
+ name, snap, flags = storage.get_name(), self.archive_name(storage), self._aws_cli_flags()
479
+ label = self.storage_label(storage)
480
+ find_snap = (f'aws ec2 describe-snapshots{flags} --owner-ids self --filters "Name=tag:Name,Values={snap}" '
481
+ f'--query "Snapshots[].SnapshotId" --output text')
482
+ if to_state == STORAGE_STATE_ARCHIVED and from_state == STORAGE_STATE_ACTIVE:
483
+ return [self._script(f"archive-{label}.sh",
484
+ f'existing=$({find_snap})\n'
485
+ f'if [ -n "$existing" ] && [ "$existing" != "None" ]; then\n'
486
+ f' echo "archive {snap} already exists: $existing"; '
487
+ f'aws ec2 wait snapshot-completed{flags} --snapshot-ids $existing; exit 0\nfi\n'
488
+ f'vol=$(aws ec2 describe-volumes{flags} --filters "Name=tag:Name,Values={name}" '
489
+ f'--query "Volumes[0].VolumeId" --output text)\n'
490
+ f'if [ -z "$vol" ] || [ "$vol" = "None" ]; then echo "no volume named {name} to archive" >&2; exit 1; fi\n'
491
+ f'snap=$(aws ec2 create-snapshot{flags} --volume-id "$vol" --description "csis archive of {name}" '
492
+ f'--tag-specifications "ResourceType=snapshot,Tags=[{{Key=Name,Value={snap}}},{{Key=csis_storage,Value={name}}}]" '
493
+ f'--query SnapshotId --output text)\n'
494
+ f'echo "archiving {name} ($vol) as {snap} ($snap)"\n'
495
+ f'aws ec2 wait snapshot-completed{flags} --snapshot-ids "$snap"\n')]
496
+ if to_state == STORAGE_STATE_DESTROYED and from_state == STORAGE_STATE_ARCHIVED:
497
+ return [self._script(f"unarchive-{label}.sh",
498
+ f'for s in $({find_snap}); do [ "$s" = "None" ] && continue; '
499
+ f'aws ec2 delete-snapshot{flags} --snapshot-id "$s" && echo "deleted archive $s"; done\ntrue\n')]
500
+ return []
501
+
502
+ def post_transition_actions(self, storage: Storage, from_state: str | None,
503
+ to_state: str) -> list[ExecutableModel]:
504
+ """Restored (archived -> active): the data is on the new volume; the
505
+ archive snapshot is deleted after the apply."""
506
+ snap, flags, label = self.archive_name(storage), self._aws_cli_flags(), self.storage_label(storage)
507
+ if from_state == STORAGE_STATE_ARCHIVED and to_state == STORAGE_STATE_ACTIVE:
508
+ return [self._script(f"restore-{label}.sh",
509
+ f'for s in $(aws ec2 describe-snapshots{flags} --owner-ids self --filters "Name=tag:Name,Values={snap}" '
510
+ f'--query "Snapshots[].SnapshotId" --output text); do [ "$s" = "None" ] && continue; '
511
+ f'aws ec2 delete-snapshot{flags} --snapshot-id "$s" && echo "restored {storage.get_name()}; archive $s deleted"; done\ntrue\n')]
512
+ return []
513
+
514
+ def module_args(self, storage: Storage) -> dict[str, Any]:
515
+ # stage 26: declared `variables:` first, then what the item decides
516
+ args: dict[str, Any] = dict(self.model.variables.as_module_args())
517
+ args.update({
518
+ "name": storage.get_name(),
519
+ "size": getattr(self.model, "size", 100),
520
+ })
521
+ # restore (stage 15): recorded archived, declared active -> the volume is
522
+ # created FROM the archive snapshot (looked up by its Name tag at plan
523
+ # time); the module ignores later drift of snapshot_id
524
+ if self._get_context().meta_state.storage_state(storage.get_name()) == STORAGE_STATE_ARCHIVED:
525
+ args["snapshot_name"] = self.archive_name(storage)
526
+ ctx = self._get_context()
527
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
528
+ networking = getattr(rtb.model, "networking", None) if rtb else None
529
+ # stage 52: the storage's OWN zone wins, then the runtime's, and only
530
+ # then the subnet the module derives one from. A declared zone has to
531
+ # pin, or declaring it would decorate the configuration without
532
+ # changing where the volume goes -- and validate would be checking a
533
+ # value the emission ignores.
534
+ az = (getattr(storage, "availability_zone", None)
535
+ or (getattr(networking, "default_availability_zone", None) if networking else None))
536
+ if az:
537
+ args["availability_zone"] = az
538
+ else:
539
+ subnet = getattr(networking, "default_subnet_id", None) if networking else None
540
+ if subnet:
541
+ args["subnet_id"] = subnet
542
+ tags = _tags_for(self, storage)
543
+ if tags:
544
+ args["tags"] = tags
545
+ return args
546
+
547
+
548
+ S = TypeVar("S", bound=TofuEfsStorageBuilderModel)
549
+ class TofuEfsStorageBuilder(TofuStorageBuilder[S]):
550
+ """OpenTofu IaC provider implementation for EFS filesystems."""
551
+
552
+ @classmethod
553
+ def csis_name(cls) -> str:
554
+ return TF_AWS_EFS
555
+
556
+ @classmethod
557
+ def capability_type(cls) -> str:
558
+ return "efs"
559
+
560
+ def attachment_cardinality(self) -> str:
561
+ return CARDINALITY_MANY
562
+
563
+ def base_image_prerequisites(self, os_family: str | None = None) -> list[str]:
564
+ fam = (os_family or "").lower()
565
+ pkg = "sudo apt-get -o DPkg::Lock::Timeout=600 install -y nfs-common" if fam in ("debian", "ubuntu") \
566
+ else "sudo yum install -y amazon-efs-utils || sudo yum install -y nfs-utils"
567
+ return [f"# storage type 'efs' prerequisites ({self.get_name()}): NFS/EFS mount tooling", pkg]
568
+
569
+ def verify_commands(self, os_family: str | None = None) -> list[str]:
570
+ return ["# verify: EFS/NFS mount tooling present",
571
+ "command -v mount.efs >/dev/null 2>&1 || command -v mount.nfs >/dev/null 2>&1 || command -v mount.nfs4 >/dev/null 2>&1"]
572
+
573
+ def _lookup(self, storage: Storage) -> dict[str, Any] | None:
574
+ efs = self._aws_client("efs")
575
+ tags_of = lambda fs: {t["Key"]: t["Value"] for t in fs.get("Tags", []) if "Key" in t} # noqa: E731
576
+ named = [fs for fs in efs.describe_file_systems().get("FileSystems", [])
577
+ if tags_of(fs).get("Name") == storage.get_name() or fs.get("Name") == storage.get_name()]
578
+ named, elsewhere = self._owned(named, tags_of) # stage 93
579
+ if not named and elsewhere:
580
+ return self._elsewhere(elsewhere, "an EFS filesystem")
581
+ for fs in named:
582
+ tags = tags_of(fs)
583
+ if True:
584
+ rec: dict[str, Any] = {"id": fs.get("FileSystemId"), "state": fs.get("LifeCycleState"), "tags": tags}
585
+ try: # observed data lifecycle (stage 15); absent = no policy
586
+ pol = efs.describe_lifecycle_configuration(FileSystemId=fs.get("FileSystemId")).get("LifecyclePolicies", [])
587
+ rec["lifecycle"] = {k: v for p in pol for k, v in p.items()} if pol else {}
588
+ except Exception: # noqa: BLE001 - informational; presence is the claim
589
+ pass
590
+ return rec
591
+ return None
592
+
593
+ def module_dirname(self) -> str:
594
+ return "aws_storage_efs"
595
+
596
+ def retag_storage(self, storage: Storage, tags: dict[str, str]) -> bool:
597
+ rec = self._lookup(storage)
598
+ if not rec or not rec.get("present", True) or not rec.get("id"):
599
+ return False
600
+ self._aws_client("efs").tag_resource(ResourceId=str(rec["id"]),
601
+ Tags=[{"Key": k, "Value": str(v)} for k, v in tags.items()])
602
+ return True
603
+
604
+ # ------------------------------------------ data lifecycle (stage 15)
605
+ IA_DAYS = (1, 7, 14, 30, 60, 90, 180, 270, 365)
606
+ ARCHIVE_DAYS = (90, 180, 270, 365)
607
+
608
+ def validate_lifecycle(self, storage: Storage) -> list[str]:
609
+ spec = getattr(storage, "lifecycle", None)
610
+ if not spec:
611
+ return []
612
+ errors: list[str] = []
613
+ if not isinstance(spec, dict):
614
+ return [f"storage '{storage.get_name()}': lifecycle must be a map"]
615
+ unknown = sorted(set(spec) - {"ia_days", "archive_days"})
616
+ if unknown:
617
+ errors.append(f"storage '{storage.get_name()}': unknown lifecycle keys {unknown} (efs: ia_days, archive_days)")
618
+ for key, allowed in (("ia_days", self.IA_DAYS), ("archive_days", self.ARCHIVE_DAYS)):
619
+ v = spec.get(key)
620
+ if v is not None and v not in allowed:
621
+ errors.append(f"storage '{storage.get_name()}': lifecycle.{key} must be one of {list(allowed)}, got {v!r}")
622
+ return errors
623
+
624
+ def module_args(self, storage: Storage) -> dict[str, Any]:
625
+ args: dict[str, Any] = dict(self.model.variables.as_module_args()) # stage 26
626
+ args["name"] = storage.get_name()
627
+ existing = storage.config.get("file_system_id")
628
+ if existing:
629
+ args["existing_file_system_id"] = existing
630
+ spec = getattr(storage, "lifecycle", None) or {}
631
+ if spec.get("ia_days"):
632
+ args["transition_to_ia"] = f"AFTER_{int(spec['ia_days'])}_DAYS"
633
+ if spec.get("archive_days"):
634
+ args["transition_to_archive"] = f"AFTER_{int(spec['archive_days'])}_DAYS"
635
+ if _groups(storage):
636
+ # EFS access points realize gid-owned subtrees natively (Q3).
637
+ args["access_points"] = self.group_subtrees(storage)
638
+ if _public_read(storage):
639
+ args["public_read"] = True
640
+ # Mount targets (stage 19): an EFS filesystem is reachable only through
641
+ # an ENI in the VPC, one per availability zone, and without them
642
+ # `mount.efs` cannot resolve the filesystem at all. Only the PRIVATE
643
+ # subnets are named: EFS permits one mount target per zone, and the
644
+ # public subnets of this VPC share the zones the private ones use.
645
+ # The clients are named as security GROUPS, so the module grants NFS by
646
+ # reference and opens no CIDR -- the standing rule for a shared network.
647
+ rtb = self._get_context().runtime_builders.get(self.model.get_runtime_provider(), None)
648
+ networking = getattr(rtb.model, "networking", None) if rtb else None
649
+ if networking is not None:
650
+ seen: set[str] = set()
651
+ subnets: list[str] = []
652
+ for subnet in getattr(networking, "subnets", None) or []:
653
+ zone = subnet.get_availability_zone() or subnet.get_subnet_id()
654
+ if subnet.get_public() or zone in seen:
655
+ continue
656
+ seen.add(zone)
657
+ subnets.append(subnet.get_subnet_id())
658
+ clients = [str(sg) for sg in getattr(networking, "addl_security_groups", None) or []]
659
+ if subnets and clients:
660
+ args["vpc_id"] = networking.network
661
+ args["mount_target_subnet_ids"] = subnets
662
+ args["client_security_group_ids"] = clients
663
+ tags = _tags_for(self, storage)
664
+ if tags:
665
+ args["tags"] = tags
666
+ return args
667
+
668
+
669
+ B = TypeVar("B", bound=TofuS3StorageBuilderModel)
670
+ class TofuS3StorageBuilder(TofuStorageBuilder[B]):
671
+ """OpenTofu IaC provider implementation for S3 buckets."""
672
+
673
+ @classmethod
674
+ def csis_name(cls) -> str:
675
+ return TF_AWS_S3
676
+
677
+ @classmethod
678
+ def capability_type(cls) -> str:
679
+ return "s3"
680
+
681
+ def attachment_cardinality(self) -> str:
682
+ return CARDINALITY_MANY
683
+
684
+ def _bucket_for(self, storage: Storage) -> str:
685
+ """The bucket a storage lives in, one rule for the module call, the
686
+ state lookup and the wipe (stage 67): the storage's own `bucket_name`,
687
+ else the BUILDER's `bucket_name` (the default for storages that name
688
+ none; since stage 63 it was read by nothing), else the storage's name."""
689
+ return (getattr(storage, "bucket_name", None) or getattr(self.model, "bucket_name", None)
690
+ or storage.get_name())
691
+
692
+ def is_posix(self) -> bool:
693
+ # Object store: "readable by all" maps to a same-account read policy
694
+ # (plugin-owned semantics, N3); groups map to per-group prefixes (N13).
695
+ return False
696
+
697
+ def base_image_prerequisites(self, os_family: str | None = None) -> list[str]:
698
+ fam = (os_family or "").lower()
699
+ tooling = ("sudo apt-get -o DPkg::Lock::Timeout=600 install -y curl unzip" if fam in ("debian", "ubuntu")
700
+ else "sudo yum install -y unzip") # curl ships on RHEL; unzip does not (found live)
701
+ return [f"# storage type 's3' prerequisites ({self.get_name()}): AWS CLI for object access",
702
+ f"command -v aws >/dev/null 2>&1 || {{ command -v unzip >/dev/null 2>&1 || {tooling}; "
703
+ "curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip && "
704
+ "cd /tmp && unzip -q awscliv2.zip && sudo ./aws/install; }"]
705
+
706
+ def verify_commands(self, os_family: str | None = None) -> list[str]:
707
+ return ["# verify: AWS CLI present", "command -v aws >/dev/null 2>&1"]
708
+
709
+ def _lookup(self, storage: Storage) -> dict[str, Any] | None:
710
+ s3 = self._aws_client("s3")
711
+ # stage 63: the STORAGE's bucket, the one its module call creates
712
+ # (bucket_name, else the storage's name); the builder's bucket_name
713
+ # made two S3 storages on one builder report the same bucket
714
+ bucket = self._bucket_for(storage)
715
+ # The bucket_name binding already scopes this lookup to one bucket, so
716
+ # presence = the bucket is reachable (found live: our own bucket read
717
+ # as HARD-missing because the module sets no Name tag).
718
+ tags: dict[str, str] = {}
719
+ try:
720
+ tagset = s3.get_bucket_tagging(Bucket=bucket).get("TagSet", [])
721
+ tags = {t["Key"]: t["Value"] for t in tagset if "Key" in t}
722
+ except Exception as e:
723
+ # Only "not there" means absent; a permission or transport failure
724
+ # must surface as *unavailable*, never as a false "missing".
725
+ code = str(getattr(e, "response", {}).get("Error", {}).get("Code", "")) if hasattr(e, "response") else ""
726
+ if code in ("NoSuchBucket", "404"):
727
+ return None
728
+ if code not in ("NoSuchTagSet",):
729
+ raise
730
+ from cs_image_system.base.lineage import belongs_to_another_configuration, owner_of
731
+ if belongs_to_another_configuration(self._get_context(), tags):
732
+ return self._elsewhere(owner_of(tags), "an S3 bucket") # stage 93: the name is theirs
733
+ rec: dict[str, Any] = {"id": bucket, "state": "active", "tags": tags}
734
+ try: # observed data lifecycle (stage 15); absent = no rules
735
+ rules = s3.get_bucket_lifecycle_configuration(Bucket=bucket).get("Rules", [])
736
+ rec["lifecycle"] = {"rules": [str(r.get("ID") or "") for r in rules]} if rules else {}
737
+ except Exception as e: # noqa: BLE001
738
+ code = str(getattr(e, "response", {}).get("Error", {}).get("Code", "")) if hasattr(e, "response") else ""
739
+ if code == "NoSuchLifecycleConfiguration":
740
+ rec["lifecycle"] = {}
741
+ return rec
742
+
743
+ def module_dirname(self) -> str:
744
+ return "aws_storage_s3"
745
+
746
+ def retag_storage(self, storage: Storage, tags: dict[str, str]) -> bool:
747
+ rec = self._lookup(storage)
748
+ if not rec or not rec.get("present", True):
749
+ return False
750
+ merged = {**(rec.get("tags") or {}), **{k: str(v) for k, v in tags.items()}} # a bucket's tag set is replaced whole
751
+ self._aws_client("s3").put_bucket_tagging(Bucket=self._bucket_for(storage),
752
+ Tagging={"TagSet": [{"Key": k, "Value": v} for k, v in merged.items()]})
753
+ return True
754
+
755
+ # ------------------------------------------ data lifecycle (stage 15)
756
+ STORAGE_CLASSES = ("STANDARD_IA", "ONEZONE_IA", "INTELLIGENT_TIERING", "GLACIER_IR", "GLACIER", "DEEP_ARCHIVE")
757
+
758
+ def validate_lifecycle(self, storage: Storage) -> list[str]:
759
+ spec = getattr(storage, "lifecycle", None)
760
+ if not spec:
761
+ return []
762
+ if not isinstance(spec, dict):
763
+ return [f"storage '{storage.get_name()}': lifecycle must be a map"]
764
+ errors: list[str] = []
765
+ unknown = sorted(set(spec) - {"transition_days", "storage_class", "expire_days", "prefix"})
766
+ if unknown:
767
+ errors.append(f"storage '{storage.get_name()}': unknown lifecycle keys {unknown} "
768
+ "(s3: transition_days, storage_class, expire_days, prefix)")
769
+ for key in ("transition_days", "expire_days"):
770
+ v = spec.get(key)
771
+ if v is not None and (not isinstance(v, int) or isinstance(v, bool) or v <= 0):
772
+ errors.append(f"storage '{storage.get_name()}': lifecycle.{key} must be a positive int, got {v!r}")
773
+ if spec.get("transition_days") and spec.get("storage_class") not in self.STORAGE_CLASSES:
774
+ errors.append(f"storage '{storage.get_name()}': lifecycle.storage_class must be one of "
775
+ f"{list(self.STORAGE_CLASSES)}, got {spec.get('storage_class')!r}")
776
+ if spec.get("storage_class") and not spec.get("transition_days"):
777
+ errors.append(f"storage '{storage.get_name()}': lifecycle.storage_class needs transition_days")
778
+ if not spec.get("transition_days") and not spec.get("expire_days"):
779
+ errors.append(f"storage '{storage.get_name()}': lifecycle declares neither transition_days nor expire_days")
780
+ return errors
781
+
782
+ def module_args(self, storage: Storage) -> dict[str, Any]:
783
+ args: dict[str, Any] = dict(self.model.variables.as_module_args()) # stage 26
784
+ args["bucket_name"] = self._bucket_for(storage)
785
+ spec = getattr(storage, "lifecycle", None) or {}
786
+ if spec:
787
+ args["lifecycle_rules"] = [{
788
+ "id": "csis", "prefix": str(spec.get("prefix") or ""),
789
+ "transition_days": int(spec.get("transition_days") or 0),
790
+ "storage_class": str(spec.get("storage_class") or ""),
791
+ "expire_days": int(spec.get("expire_days") or 0)}]
792
+ if _groups(storage):
793
+ args["group_prefixes"] = sorted(_groups(storage))
794
+ if _public_read(storage):
795
+ args["public_read"] = True
796
+ tags = _tags_for(self, storage)
797
+ if tags:
798
+ args["tags"] = tags
799
+ return args
800
+
801
+ def transition_actions(self, storage: Storage, from_state: str | None,
802
+ to_state: str) -> list[ExecutableModel]:
803
+ """``destroyed`` wipes the bucket clean before terraform removes it
804
+ (N19: "wiped clean"); other transitions need no bucket-level action."""
805
+ if to_state != STORAGE_STATE_DESTROYED:
806
+ return []
807
+ ctx = self._get_context()
808
+ rtb = ctx.runtime_builders.get(self.model.get_runtime_provider(), None)
809
+ creds = rtb.model.get_credentials() if rtb else None
810
+ profile = (creds or {}).get("profile_name")
811
+ bucket = self._bucket_for(storage)
812
+ args = ["s3", "rm", f"s3://{bucket}", "--recursive"]
813
+ if profile:
814
+ args += ["--profile", profile]
815
+ e = ExecutableModel(name="aws", type_="executable", binary="aws")
816
+ e.args = args
817
+ e.working_directory = self.get_path_for_phase(
818
+ ExecutionLifecyclePhase.STORAGE_GENERATION, suffix=".tf").parent
819
+ return [e]
820
+
821
+
822
+ class TofuStorageVersionChecker(AbstractVersionChecker):
823
+ """Version checker for OpenTofu."""
824
+
825
+ @classmethod
826
+ def csis_name(cls) -> str:
827
+ return TF_AWS
828
+
829
+ def get_version_params(self) -> list[str]:
830
+ return ["--version", "-json"]
831
+
832
+ def get_extracted_string(self, res: subprocess.CompletedProcess[str]) -> str | None:
833
+ return json.loads(res.stdout.strip()).get("terraform_version", None)