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