cs-image-system-base 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.
Files changed (143) hide show
  1. cs_image_system/base/__init__.py +3 -0
  2. cs_image_system/base/accounts_reconcile.py +107 -0
  3. cs_image_system/base/alias_pool.py +258 -0
  4. cs_image_system/base/ansible_launch.py +96 -0
  5. cs_image_system/base/bake_steps.py +103 -0
  6. cs_image_system/base/bake_user.py +112 -0
  7. cs_image_system/base/basic/abstract_plugin_metadata.py +56 -0
  8. cs_image_system/base/basic/abstract_version_checker.py +71 -0
  9. cs_image_system/base/basic/asset.py +246 -0
  10. cs_image_system/base/basic/builder_base.py +209 -0
  11. cs_image_system/base/basic/builder_base_cloud.py +18 -0
  12. cs_image_system/base/basic/builder_base_container.py +17 -0
  13. cs_image_system/base/basic/builder_base_group.py +316 -0
  14. cs_image_system/base/basic/builder_base_image.py +152 -0
  15. cs_image_system/base/basic/builder_base_instance.py +44 -0
  16. cs_image_system/base/basic/builder_base_mod.py +48 -0
  17. cs_image_system/base/basic/builder_base_os.py +319 -0
  18. cs_image_system/base/basic/builder_base_runtime.py +362 -0
  19. cs_image_system/base/basic/builder_base_state.py +19 -0
  20. cs_image_system/base/basic/builder_base_storage.py +114 -0
  21. cs_image_system/base/basic/builder_base_user.py +95 -0
  22. cs_image_system/base/bootstrap/__init__.py +36 -0
  23. cs_image_system/base/bootstrap/answers.py +50 -0
  24. cs_image_system/base/bootstrap/facts.py +192 -0
  25. cs_image_system/base/bootstrap/generate.py +180 -0
  26. cs_image_system/base/bootstrap/github.py +126 -0
  27. cs_image_system/base/bootstrap/hcl.py +238 -0
  28. cs_image_system/base/bootstrap/interview.py +91 -0
  29. cs_image_system/base/bootstrap/questions.py +165 -0
  30. cs_image_system/base/bootstrap/sections.py +36 -0
  31. cs_image_system/base/capabilities.py +231 -0
  32. cs_image_system/base/commands/__init__.py +23 -0
  33. cs_image_system/base/commands/dispose.py +170 -0
  34. cs_image_system/base/commands/emission.py +292 -0
  35. cs_image_system/base/commands/finalization.py +41 -0
  36. cs_image_system/base/commands/gate.py +95 -0
  37. cs_image_system/base/commands/gen_groups.py +60 -0
  38. cs_image_system/base/commands/gen_images.py +64 -0
  39. cs_image_system/base/commands/gen_instances.py +59 -0
  40. cs_image_system/base/commands/gen_storages.py +47 -0
  41. cs_image_system/base/commands/gen_users.py +52 -0
  42. cs_image_system/base/commands/identity_gids.py +48 -0
  43. cs_image_system/base/commands/load_failures.py +101 -0
  44. cs_image_system/base/commands/login_proof.py +172 -0
  45. cs_image_system/base/commands/preflight.py +310 -0
  46. cs_image_system/base/commands/relabel.py +197 -0
  47. cs_image_system/base/commands/resolve.py +182 -0
  48. cs_image_system/base/commands/restamp.py +96 -0
  49. cs_image_system/base/commands/run_lifecycles.py +732 -0
  50. cs_image_system/base/commands/run_scope.py +111 -0
  51. cs_image_system/base/commands/runtime_facts.py +208 -0
  52. cs_image_system/base/commands/state_migration.py +253 -0
  53. cs_image_system/base/commands/unmount.py +101 -0
  54. cs_image_system/base/commands/upgrade.py +83 -0
  55. cs_image_system/base/commands/validate.py +733 -0
  56. cs_image_system/base/commands/verify_instance.py +190 -0
  57. cs_image_system/base/commands/workload_token.py +164 -0
  58. cs_image_system/base/constants.py +204 -0
  59. cs_image_system/base/encryption.py +587 -0
  60. cs_image_system/base/generations.py +185 -0
  61. cs_image_system/base/global_context.py +1538 -0
  62. cs_image_system/base/helpers/async_utils.py +74 -0
  63. cs_image_system/base/helpers/config.py +18 -0
  64. cs_image_system/base/helpers/field_helpers.py +81 -0
  65. cs_image_system/base/helpers/field_kinds.py +94 -0
  66. cs_image_system/base/helpers/plugin_config.py +17 -0
  67. cs_image_system/base/helpers/resolution_stages.py +39 -0
  68. cs_image_system/base/helpers/yaml_process.py +3 -0
  69. cs_image_system/base/identity_attributes.py +206 -0
  70. cs_image_system/base/image_tests.py +275 -0
  71. cs_image_system/base/init.py +30 -0
  72. cs_image_system/base/launch_enrollment.py +67 -0
  73. cs_image_system/base/launch_params.py +677 -0
  74. cs_image_system/base/lifecycle.py +69 -0
  75. cs_image_system/base/lifecycles.py +198 -0
  76. cs_image_system/base/lineage.py +729 -0
  77. cs_image_system/base/loader.py +61 -0
  78. cs_image_system/base/materialize.py +288 -0
  79. cs_image_system/base/meta_state.py +707 -0
  80. cs_image_system/base/mod_tests.py +396 -0
  81. cs_image_system/base/models/base_image.py +407 -0
  82. cs_image_system/base/models/builder_model.py +293 -0
  83. cs_image_system/base/models/cloud.py +22 -0
  84. cs_image_system/base/models/cloud_builder.py +37 -0
  85. cs_image_system/base/models/container.py +23 -0
  86. cs_image_system/base/models/container_builder.py +30 -0
  87. cs_image_system/base/models/credentials.py +50 -0
  88. cs_image_system/base/models/encryption_config.py +19 -0
  89. cs_image_system/base/models/executable.py +194 -0
  90. cs_image_system/base/models/executable_adds.py +37 -0
  91. cs_image_system/base/models/group.py +144 -0
  92. cs_image_system/base/models/group_builder.py +39 -0
  93. cs_image_system/base/models/ia_config.py +133 -0
  94. cs_image_system/base/models/image.py +454 -0
  95. cs_image_system/base/models/image_builder_model.py +25 -0
  96. cs_image_system/base/models/instance.py +219 -0
  97. cs_image_system/base/models/instance_builder.py +26 -0
  98. cs_image_system/base/models/mod_builder.py +21 -0
  99. cs_image_system/base/models/model_config.py +21 -0
  100. cs_image_system/base/models/moditem_type.py +38 -0
  101. cs_image_system/base/models/module_variables.py +43 -0
  102. cs_image_system/base/models/os_builder_model.py +168 -0
  103. cs_image_system/base/models/os_builder_runtime_config.py +211 -0
  104. cs_image_system/base/models/provider_specific_image.py +198 -0
  105. cs_image_system/base/models/public_safe_config.py +23 -0
  106. cs_image_system/base/models/root_item.py +69 -0
  107. cs_image_system/base/models/runtime.py +166 -0
  108. cs_image_system/base/models/runtime_builder_item.py +13 -0
  109. cs_image_system/base/models/state_builder.py +19 -0
  110. cs_image_system/base/models/storage.py +148 -0
  111. cs_image_system/base/models/storage_builder.py +26 -0
  112. cs_image_system/base/models/update_policy.py +105 -0
  113. cs_image_system/base/models/user.py +239 -0
  114. cs_image_system/base/models/user_builder.py +58 -0
  115. cs_image_system/base/orchestrator.py +775 -0
  116. cs_image_system/base/posix_ids.py +113 -0
  117. cs_image_system/base/power_state.py +176 -0
  118. cs_image_system/base/protocols/builder_model_protocol.py +40 -0
  119. cs_image_system/base/protocols/name_typed_protocol.py +36 -0
  120. cs_image_system/base/protocols/parent_property_holding_protocol.py +95 -0
  121. cs_image_system/base/protocols/plugin_metadata.py +56 -0
  122. cs_image_system/base/protocols/state_management.py +21 -0
  123. cs_image_system/base/provider_aliases.py +320 -0
  124. cs_image_system/base/public_safe.py +394 -0
  125. cs_image_system/base/py.typed +0 -0
  126. cs_image_system/base/read_models.py +145 -0
  127. cs_image_system/base/registry.py +296 -0
  128. cs_image_system/base/release.py +254 -0
  129. cs_image_system/base/reserved_names.py +121 -0
  130. cs_image_system/base/retention.py +118 -0
  131. cs_image_system/base/singleton.py +32 -0
  132. cs_image_system/base/state_query.py +700 -0
  133. cs_image_system/base/storage_state.py +190 -0
  134. cs_image_system/base/template_utils.py +687 -0
  135. cs_image_system/base/tofu_lock.py +70 -0
  136. cs_image_system/base/utils.py +263 -0
  137. cs_image_system/base/v2_validation.py +241 -0
  138. cs_image_system/base/workload_access.py +67 -0
  139. cs_image_system_base-0.1.1.dist-info/METADATA +2558 -0
  140. cs_image_system_base-0.1.1.dist-info/RECORD +143 -0
  141. cs_image_system_base-0.1.1.dist-info/WHEEL +4 -0
  142. cs_image_system_base-0.1.1.dist-info/entry_points.txt +2 -0
  143. cs_image_system_base-0.1.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,107 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Accounts on machines (stage 75 step 4): after an applying instance run,
6
+ every launched, RUNNING machine whose owning group's builder renders an
7
+ accounts script gets that script, over the runtime's session.
8
+
9
+ Why after launch and not in the launch script: the launch script is the
10
+ machine's recorded, immutable snapshot (its hash is a launch parameter), so
11
+ a member list inside it would make every membership change a change to the
12
+ machine. Run here, on every applying run, a membership change is neither a
13
+ re-bake nor a replacement, and a standing machine is brought up to date in
14
+ place.
15
+
16
+ The script is the plugin's (the posix plugin's ``accounts_script``): run as
17
+ root, idempotent, adopting what is equal and refusing what differs. Its own
18
+ lines start ``posix accounts:``; they are the report, logged here. A
19
+ failure is logged as an error and never stops the run -- the apply has
20
+ happened, and the next applying run tries again -- the same rule as the
21
+ provider aliases (stage 58). A machine that is off is the operator's
22
+ decision (stage 57): it waits for a run that finds it running.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from . import power_state
31
+ from .capabilities import group_builder_of
32
+ from .lifecycles import Lifecycle
33
+
34
+ if TYPE_CHECKING:
35
+ from .global_context import GlobalTypeContext
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+ REACHABLE_WAIT_SECONDS = 600 # a machine that booted this run: its session agent comes up later
40
+ SCRIPT_TIMEOUT_SECONDS = 300
41
+ HEREDOC = "CSIS_ACCOUNTS"
42
+
43
+
44
+ def _group_of(ctx: "GlobalTypeContext", instance: Any) -> str:
45
+ image = ctx.images_map.get(str(instance.image))
46
+ return str(getattr(image, "group", None) or "") if image is not None else ""
47
+
48
+
49
+ def as_root(script: str) -> str:
50
+ """The script, run as root whichever user the session lands in."""
51
+ return f"sudo bash -s <<'{HEREDOC}'\n{script.rstrip()}\n{HEREDOC}\n"
52
+
53
+
54
+ def reconcile_accounts(ctx: "GlobalTypeContext", lifecycle: Lifecycle) -> None:
55
+ if lifecycle != Lifecycle.INSTANCE_IMAGE:
56
+ return
57
+ from .utils import apply_enabled
58
+ if not apply_enabled("instances"):
59
+ return
60
+ recorded = ctx.meta_state.launch_params()
61
+ for instance in ctx.instances:
62
+ name = instance.get_name()
63
+ if not apply_enabled("instances", str(instance.type_), [str(instance.runtime)]):
64
+ continue
65
+ if not (recorded.get(name) or {}).get("launched"):
66
+ continue
67
+ group = _group_of(ctx, instance)
68
+ gb = group_builder_of(ctx, group) if group else None
69
+ try:
70
+ script = gb.accounts_script(group) if gb is not None else None
71
+ except Exception as e: # noqa: BLE001 - a provider that cannot be asked (OPA's gid): reported, never fatal
72
+ log.error(f"Instance {name}: the accounts script of group {group} could not be made: {e}")
73
+ continue
74
+ if not script:
75
+ continue
76
+ rtb = ctx.runtime_builders.get(str(instance.runtime)) if instance.runtime else None
77
+ if rtb is None:
78
+ continue
79
+ state = rtb.query_instance_power_state(name) if rtb.can_query_instance_power_state() else None
80
+ if state is not None and not power_state.is_on(state):
81
+ log.info(f"Instance {name}: {power_state.describe(state)}; its accounts wait for a run that finds it "
82
+ "running (accounts are not worth starting a machine for)")
83
+ continue
84
+ if str((recorded.get(name) or {}).get("launched_run") or "") == str(ctx.run_id):
85
+ if not power_state.wait_until_reachable(rtb, name, timeout=REACHABLE_WAIT_SECONDS):
86
+ log.warning(f"Instance {name}: not reachable within {REACHABLE_WAIT_SECONDS}s of its launch; "
87
+ "its accounts wait for the next applying run")
88
+ continue
89
+ try:
90
+ rc, out = rtb.run_session_command(name, as_root(script), timeout=SCRIPT_TIMEOUT_SECONDS)
91
+ except Exception as e: # noqa: BLE001 - reported, never fatal to the run
92
+ log.error(f"Instance {name}: could not run the accounts script of group {group}: {e}")
93
+ continue
94
+ report = [ln.strip() for ln in out.splitlines() if ln.strip().startswith("posix accounts:")]
95
+ done = rc == 0 and "posix accounts: in place" in report
96
+ for line in report:
97
+ if line != "posix accounts: in place":
98
+ (log.warning if done else log.error)(f"Instance {name}: {line}")
99
+ if done:
100
+ log.info(f"Instance {name}: the accounts of group {group} are in place")
101
+ else:
102
+ log.error(f"Instance {name}: the accounts script of group {group} FAILED (exit {rc}); "
103
+ f"{out.strip()[-300:] if not report else 'see above'}")
104
+
105
+
106
+ def register(runner) -> None:
107
+ runner.register_after_apply(reconcile_accounts)
@@ -0,0 +1,258 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Stage 59: a pool of names, each spent once.
6
+
7
+ ``meta-state/aliases.txt`` in the configuration is a pool of pre-approved,
8
+ memorable names, hand-written in advance, one per line. When a NEW machine
9
+ of a durable instance is about to be launched, the system takes the first
10
+ line that is not commented out, records it as that machine's alias in its
11
+ launch parameters, and comments the line out IN PLACE, on the same line,
12
+ with what took it and when. A name is therefore spent exactly once,
13
+ permanently, and the file is both the supply and the ledger.
14
+
15
+ Optional by construction: no file, no aliases, no error. The division of
16
+ bytes with the operator is the whole contract: a human only ever APPENDS
17
+ lines; the system only ever comments out lines that are already there.
18
+
19
+ The burn is the claim and it comes first: the line is rewritten before the
20
+ machine exists (the launch parameters are recorded at generation, the
21
+ apply follows), so a run that dies between the two costs one name and
22
+ never lets two machines answer to one. A dry run draws nothing and says
23
+ what it would take. A run whose instance roots may not apply draws
24
+ nothing either: a name is burned only by the run that can launch.
25
+ Ephemeral instances draw nothing -- a proof machine that comes and goes
26
+ each cycle would drain the pool for nobody. Spent is spent: a decommission
27
+ returns nothing.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import fcntl
32
+ import logging
33
+ import re
34
+ from dataclasses import dataclass
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ if TYPE_CHECKING:
40
+ from .global_context import GlobalTypeContext
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ POOL_FILE = "aliases.txt"
45
+ _SPENT_RE = re.compile(r"^\s*#\s*(?P<name>[A-Za-z0-9][A-Za-z0-9-]*)\s+--\s+(?P<record>.+?)\s*$")
46
+
47
+ # names drawn by THIS process, per run and instance: the launch parameters
48
+ # are computed more than once per run, and a second computation must find
49
+ # the name the first one burned, not burn another
50
+ _DRAWN: dict[tuple[str, str], str] = {}
51
+
52
+
53
+ @dataclass
54
+ class PoolLine:
55
+ index: int
56
+ text: str
57
+ name: str | None # a free name, or the name a spent line records; None for blanks/other comments
58
+ spent: bool
59
+
60
+
61
+ def pool_path(config_root: Path | str) -> Path:
62
+ from .meta_state import META_STATE_DIRNAME
63
+ return Path(config_root) / META_STATE_DIRNAME / POOL_FILE
64
+
65
+
66
+ def parse(text: str) -> list[PoolLine]:
67
+ out: list[PoolLine] = []
68
+ for i, raw in enumerate(text.splitlines()):
69
+ line = raw.strip()
70
+ if not line:
71
+ out.append(PoolLine(i, raw, None, False))
72
+ elif line.startswith("#"):
73
+ m = _SPENT_RE.match(raw)
74
+ out.append(PoolLine(i, raw, m.group("name") if m else None, True))
75
+ else:
76
+ out.append(PoolLine(i, raw, line, False))
77
+ return out
78
+
79
+
80
+ def read_pool(path: Path) -> list[PoolLine]:
81
+ return parse(path.read_text()) if path.is_file() else []
82
+
83
+
84
+ def free_names(path: Path) -> list[str]:
85
+ return [ln.name for ln in read_pool(path) if not ln.spent and ln.name]
86
+
87
+
88
+ def spent_names(path: Path) -> dict[str, str]:
89
+ """``{name: record}`` for every line the system has commented out."""
90
+ out: dict[str, str] = {}
91
+ for ln in read_pool(path):
92
+ if ln.spent and ln.name:
93
+ m = _SPENT_RE.match(ln.text)
94
+ out[ln.name] = m.group("record") if m else ""
95
+ return out
96
+
97
+
98
+ def peek(path: Path) -> str | None:
99
+ """The name the next draw would take, without taking it."""
100
+ free = free_names(path)
101
+ return free[0] if free else None
102
+
103
+
104
+ def pool_problems(path: Path, *, reserved: set[str] | None = None) -> list[str]:
105
+ """Why a free line could not be used as it stands: not a legal hostname
106
+ label (RFC 1123, at most 63 characters -- it goes into OPA as an
107
+ AltNames entry beside the canonical name, stage 58), a repeat of another
108
+ free line, or a name the configuration already gives an instance."""
109
+ from .launch_params import hostname_problems
110
+ problems: list[str] = []
111
+ seen: set[str] = set()
112
+ for ln in read_pool(path):
113
+ if ln.spent or not ln.name:
114
+ continue
115
+ for why in hostname_problems(ln.name):
116
+ problems.append(f"line {ln.index + 1}: {ln.name!r} {why}")
117
+ if ln.name in seen:
118
+ problems.append(f"line {ln.index + 1}: {ln.name!r} repeats an earlier free line")
119
+ seen.add(ln.name)
120
+ if reserved and ln.name in reserved:
121
+ problems.append(f"line {ln.index + 1}: {ln.name!r} is a name the configuration already gives an instance")
122
+ return problems
123
+
124
+
125
+ def burn_record(taken_by: str, run_id: str, when: datetime | None = None) -> str:
126
+ when = when or datetime.now(timezone.utc)
127
+ return f"{taken_by} {when.isoformat(timespec='seconds').replace('+00:00', 'Z')} run {run_id}"
128
+
129
+
130
+ def draw(path: Path, *, taken_by: str, run_id: str, when: datetime | None = None) -> str | None:
131
+ """Take the first free name: comment its line out in place with the
132
+ record, under an exclusive lock, written temp-then-replace so a reader
133
+ never sees a half-written pool. None when the pool is absent or empty.
134
+ Two drawers in one checkout serialise on the lock; across checkouts the
135
+ push settles it (a rejected push on this file means someone else took
136
+ that name: re-read and draw again, never force)."""
137
+ if not path.is_file():
138
+ return None
139
+ lock = path.with_suffix(path.suffix + ".lock")
140
+ with open(lock, "w") as lk:
141
+ fcntl.flock(lk, fcntl.LOCK_EX)
142
+ try:
143
+ lines = read_pool(path) # re-read under the lock
144
+ target = next((ln for ln in lines if not ln.spent and ln.name), None)
145
+ if target is None:
146
+ return None
147
+ record = burn_record(taken_by, run_id, when)
148
+ raw = path.read_text().splitlines(keepends=True)
149
+ ending = "\n" if raw[target.index].endswith("\n") else ""
150
+ raw[target.index] = f"# {target.name} -- {record}{ending}"
151
+ tmp = path.with_suffix(path.suffix + ".tmp")
152
+ tmp.write_text("".join(raw))
153
+ tmp.replace(path)
154
+ return target.name
155
+ finally:
156
+ fcntl.flock(lk, fcntl.LOCK_UN)
157
+
158
+
159
+ def undraw(path: Path, *, name: str, run_id: str) -> bool:
160
+ """Give a name back: the line ``draw`` burned for ``name`` in run
161
+ ``run_id`` becomes a free line again, in place, under the same lock.
162
+ Only a record naming THAT run is restored (a name another run gave a
163
+ machine is spent for good). True when a line was restored."""
164
+ if not path.is_file():
165
+ return False
166
+ lock = path.with_suffix(path.suffix + ".lock")
167
+ with open(lock, "w") as lk:
168
+ fcntl.flock(lk, fcntl.LOCK_EX)
169
+ try:
170
+ raw = path.read_text().splitlines(keepends=True)
171
+ for ln in read_pool(path):
172
+ if ln.spent and ln.name == name and f" run {run_id}" in raw[ln.index]:
173
+ ending = "\n" if raw[ln.index].endswith("\n") else ""
174
+ raw[ln.index] = f"{name}{ending}"
175
+ tmp = path.with_suffix(path.suffix + ".tmp")
176
+ tmp.write_text("".join(raw))
177
+ tmp.replace(path)
178
+ return True
179
+ return False
180
+ finally:
181
+ fcntl.flock(lk, fcntl.LOCK_UN)
182
+
183
+
184
+ def release_unlaunched(ctx: "GlobalTypeContext") -> list[str]:
185
+ """When a run ends: every name this run drew for a machine that did not
186
+ come to exist goes back to the pool (hygiene VIII item 5). A draw
187
+ happens at generation; the machine exists only after the apply, and a
188
+ run can abort between the two (``tofu init`` failing to download a
189
+ provider did it on 2026-10-01) with nothing recorded -- the pool file on
190
+ disk would still show the name spent for a machine that never was, and
191
+ the retry would burn the next one. A name is GIVEN when the instance's
192
+ launch record is marked launched by this run with that alias; every
193
+ other draw of this run is released. Returns the names released."""
194
+ ms = ctx.meta_state
195
+ path = pool_path(ctx.working_path)
196
+ released: list[str] = []
197
+ for key in [k for k in list(_DRAWN) if k[0] == str(ctx.run_id)]:
198
+ run_id, name = key
199
+ drawn = _DRAWN[key]
200
+ rec = ms.launch_params().get(name) or {}
201
+ given = bool(rec.get("launched")) and str(rec.get("launched_run") or "") == run_id \
202
+ and str(rec.get("alias") or "") == drawn
203
+ if given:
204
+ continue
205
+ if undraw(path, name=drawn, run_id=run_id):
206
+ released.append(drawn)
207
+ log.warning(f"Instance {name}: alias {drawn!r} drawn this run goes back to the pool -- the "
208
+ f"machine it was for never came to exist (the run did not launch it)")
209
+ _DRAWN.pop(key, None)
210
+ return released
211
+
212
+
213
+ def _new_machine(ctx: "GlobalTypeContext", instance: Any) -> bool:
214
+ """Whether the launch parameters being computed are for a machine that
215
+ does not exist yet: never launched, or a pending/follow replacement."""
216
+ from .launch_params import will_replace
217
+ recorded = ctx.meta_state.launch_params().get(instance.get_name()) or {}
218
+ return not recorded.get("launched") or will_replace(ctx, instance)
219
+
220
+
221
+ def alias_for(ctx: "GlobalTypeContext", instance: Any, canonical: str) -> str | None:
222
+ """The alias this instance's launch parameters carry: the recorded one
223
+ for a machine that stands, a fresh draw for a new machine of a durable
224
+ instance when THIS run can launch it, nothing otherwise."""
225
+ name = instance.get_name()
226
+ ms = ctx.meta_state
227
+ recorded = ms.launch_params().get(name) or {}
228
+ if not _new_machine(ctx, instance):
229
+ return str(recorded["alias"]) if recorded.get("alias") else None
230
+ if getattr(instance, "ephemeral", False):
231
+ return None
232
+ path = pool_path(ctx.working_path)
233
+ if not path.is_file():
234
+ return None
235
+ key = (str(ctx.run_id), name)
236
+ if key in _DRAWN:
237
+ return _DRAWN[key]
238
+ if str(recorded.get("run") or "") == str(ctx.run_id) and recorded.get("alias"):
239
+ return str(recorded["alias"]) # drawn earlier in this run, already recorded
240
+ from .utils import apply_enabled
241
+ can_launch = (not ctx.dry_run) and apply_enabled("instances", str(instance.type_), [str(instance.runtime)])
242
+ if not can_launch:
243
+ would = peek(path)
244
+ if would:
245
+ log.info(f"Instance {name}: would take alias {would!r} from the pool for {canonical} "
246
+ f"({'dry run' if ctx.dry_run else 'this run cannot launch it'}; nothing drawn)")
247
+ else:
248
+ log.warning(f"Instance {name}: the alias pool is EMPTY; a launch proceeds without an alias")
249
+ return None
250
+ drawn = draw(path, taken_by=f"instance {name} as {canonical}", run_id=str(ctx.run_id))
251
+ if drawn is None:
252
+ log.warning(f"Instance {name}: the alias pool is EMPTY; launching {canonical} without an alias "
253
+ f"(append names to {path.name} to refill it)")
254
+ return None
255
+ _DRAWN[key] = drawn
256
+ log.info(f"Instance {name}: took alias {drawn!r} from the pool for {canonical} (spent; the line is "
257
+ f"commented out in {path.name})")
258
+ return drawn
@@ -0,0 +1,96 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Prototype: launch parameters as an ansible inventory + playbook
6
+ (EXPLORE "Possible Other Infra-as-Code tools", step 2 -- investigation).
7
+
8
+ The instance root feeds :func:`launch_params.user_data_template` through
9
+ ``templatefile`` so cloud-init runs the mounts / group subtree / enrollment
10
+ at first boot. The same *structural* launch parameters can be rendered as
11
+ an ansible playbook for targets that have no user-data (metal, containers,
12
+ an already-running host): identical decisions, a different executor.
13
+
14
+ Runtime values still arrive by reference, as extra-vars rather than
15
+ terraform template variables: ``group_gid``, ``efs`` (storage ->
16
+ {file_system_id, access_point_id}) and ``sft_enrollment_token``. Nothing
17
+ here reads meta-state or the cloud; it is a pure rendering of what
18
+ ``compute_launch_params`` already produced.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+ from .launch_enrollment import enrollment_kind_for
27
+
28
+ EXTRA_VARS = ("group_gid", "efs", "sft_enrollment_token")
29
+
30
+
31
+ def launch_inventory(params_by_instance: dict[str, dict[str, Any]]) -> str:
32
+ """One host per instance, grouped by owning group and identity type."""
33
+ hosts = {name: {"csis_image": p.get("image"), "csis_build": p.get("build")}
34
+ for name, p in sorted(params_by_instance.items())}
35
+ children: dict[str, dict[str, Any]] = {}
36
+ for name, p in sorted(params_by_instance.items()):
37
+ for key in (p.get("group"), p.get("identity_type")):
38
+ if key:
39
+ children.setdefault(f"csis_{key}", {"hosts": {}})["hosts"][name] = {}
40
+ inv: dict[str, Any] = {"all": {"hosts": hosts, "children": children}}
41
+ return yaml.safe_dump(inv, sort_keys=False)
42
+
43
+
44
+ def launch_playbook(params: dict[str, Any], inventory_name: str | None = None) -> str:
45
+ """The playbook equivalent of ``user_data_template(params)``.
46
+ ``inventory_name`` is the instance's declared name -- the key
47
+ ``launch_inventory`` uses -- and is what the play TARGETS;
48
+ ``params["hostname"]`` is the canonical name the machine is GIVEN (since
49
+ stage 55 step 4 the two differ: ``test`` versus ``test-001``). Without
50
+ it the play targets the hostname, as it did when they coincided."""
51
+ host = params["hostname"]
52
+ target = inventory_name or host
53
+ group = params.get("group")
54
+ tasks: list[dict[str, Any]] = [
55
+ {"name": f"hostname {host}", "ansible.builtin.hostname": {"name": host}},
56
+ ]
57
+ for m in params.get("mounts", []):
58
+ mp, name, stype = m["mount_point"], m["storage"], m["type"]
59
+ if stype == "s3":
60
+ tasks.append({"name": f"storage {name} (s3): object access via the AWS CLI, no mount",
61
+ "ansible.builtin.debug": {"msg": f"s3 storage {name} is not mounted"}})
62
+ continue
63
+ tasks.append({"name": f"mount point {mp} for storage {name} ({stype})",
64
+ "ansible.builtin.file": {"path": mp, "state": "directory"}})
65
+ if stype == "ebs":
66
+ dev = m["device"]
67
+ tasks.append({"name": f"filesystem on {dev} ({name})",
68
+ "community.general.filesystem": {"fstype": "xfs", "dev": dev}})
69
+ tasks.append({"name": f"mount {dev} at {mp}",
70
+ "ansible.posix.mount": {"path": mp, "src": dev, "fstype": "xfs",
71
+ "opts": "defaults,nofail", "state": "mounted"}})
72
+ if group:
73
+ tasks.append({"name": f"private subtree {mp}/{group} (gid by reference)",
74
+ "ansible.builtin.file": {"path": f"{mp}/{group}", "state": "directory",
75
+ "group": "{{ group_gid }}",
76
+ "mode": str(m["share_mode"])}})
77
+ elif stype == "efs":
78
+ tasks.append({"name": f"mount EFS {name} at {mp}",
79
+ "ansible.posix.mount": {
80
+ "path": mp, "fstype": "efs",
81
+ "src": "{{ efs['" + name + "'].file_system_id }}:/",
82
+ "opts": "_netdev,tls,accesspoint={{ efs['" + name + "'].access_point_id }},nofail",
83
+ "state": "mounted"}})
84
+ # stage 75 step 2: the identity plugin that issued the enrollment renders its tasks
85
+ kind = enrollment_kind_for(params)
86
+ if kind is not None:
87
+ tasks += kind.ansible_tasks(params)
88
+ play = {
89
+ "name": f"cs-image-system launch parameters for {host} (N26; immutable after launch)",
90
+ "hosts": target,
91
+ "become": True,
92
+ "vars": {"csis_image": params.get("image"), "csis_build": params.get("build"),
93
+ "csis_group": group, "csis_session": params.get("session")},
94
+ "tasks": tasks,
95
+ }
96
+ return yaml.safe_dump([play], sort_keys=False, width=120)
@@ -0,0 +1,103 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """How a bake's shell steps run, and what a bake waits for first (stage 86).
6
+
7
+ A build machine is not the bake's alone. Its own first-boot script may still
8
+ be installing something, and the account's fleet management may act on every
9
+ new machine the moment its agent registers: found walking the daily driver
10
+ (stage 65, finding F29), where Systems Manager's associations -- agent
11
+ updates, a patch scan, inventory, all targeting every instance -- fired on
12
+ each build machine within forty seconds of launch and held the package
13
+ database while packer provisioned. ``rpm`` waits for a held transaction lock
14
+ only when stdin is a terminal; in a bake it fails at once ("can't create
15
+ transaction lock"), and the bake with it.
16
+
17
+ Two things answer that, and both are emitted, reviewable shell:
18
+
19
+ * the FIRST provisioner of every build block settles the machine -- the
20
+ first-boot script has ended, the runtime's fleet management is quiet
21
+ (``RuntimeBuilderBase.bake_settle_commands``) -- and writes a small step
22
+ runner to ``/run``, a tmpfs, so it is never part of the image;
23
+ * every shell provisioner the system emits runs its script through that
24
+ runner (``EXECUTE_COMMAND``): a step that FAILS and whose output says the
25
+ package database was held is run again after a wait, a bounded number of
26
+ times. Any other failure is the step's own and fails at once, as before.
27
+
28
+ A step the runner cannot be found for (a machine that rebooted mid-bake lost
29
+ ``/run``) runs as it always did.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ #: Where the step runner is written on the build machine: a tmpfs, so no
34
+ #: image ever carries it. Read with ``sh`` (Debian mounts /run noexec).
35
+ STEP_RUNNER = "/run/csis-step"
36
+
37
+ #: How many times a step is run in all, and the wait between two runs.
38
+ STEP_TRIES = 10
39
+ STEP_WAIT_SECONDS = 30
40
+
41
+ #: What a package tool says when someone else holds the package database:
42
+ #: rpm and dnf ("can't create transaction lock"), apt and dpkg.
43
+ BUSY_PATTERN = ("transaction lock|Could not get lock|dpkg frontend lock|"
44
+ "Unable to lock the administration directory")
45
+
46
+ #: How long the settle waits for the machine's first-boot script.
47
+ BOOT_WAIT_SECONDS = 300
48
+
49
+ #: The ``execute_command`` of every shell provisioner the system emits:
50
+ #: packer's own default (``chmod +x {{ .Path }}; {{ .Vars }} {{ .Path }}``)
51
+ #: with the script handed to the step runner when the machine has one.
52
+ EXECUTE_COMMAND = ("chmod +x {{ .Path }}; if [ -r " + STEP_RUNNER + " ]; then {{ .Vars }} sh " + STEP_RUNNER
53
+ + " {{ .Path }}; else {{ .Vars }} {{ .Path }}; fi")
54
+
55
+
56
+ def execute_command_line(indent: str = " ") -> str:
57
+ """The HCL attribute line a shell provisioner block carries."""
58
+ return f'{indent}execute_command = "{EXECUTE_COMMAND}"'
59
+
60
+
61
+ def step_runner_lines() -> list[str]:
62
+ """The step runner, a line each. POSIX sh; it streams the step's output as
63
+ it comes and keeps a copy to read when the step fails."""
64
+ return [
65
+ "#!/bin/sh",
66
+ "# cs-image-system: runs ONE step of a bake. A step that fails while the package",
67
+ "# database is held by another process is run again after a wait; any other",
68
+ "# failure is the step's own and is returned at once.",
69
+ f'tries="${{CSIS_STEP_TRIES:-{STEP_TRIES}}}"',
70
+ f'pause="${{CSIS_STEP_WAIT:-{STEP_WAIT_SECONDS}}}"',
71
+ 'out="$(mktemp)" || exit 1',
72
+ "trap 'rm -f \"$out\" \"$out.rc\"' EXIT",
73
+ "n=1",
74
+ "while :; do",
75
+ ' { "$@" 2>&1; echo "$?" > "$out.rc"; } | tee "$out"',
76
+ ' rc="$(cat "$out.rc")"',
77
+ ' [ "$rc" = 0 ] && exit 0',
78
+ ' [ "$n" -ge "$tries" ] && exit "$rc"',
79
+ f" grep -Eq '{BUSY_PATTERN}' \"$out\" || exit \"$rc\"",
80
+ ' echo "csis-step: the package database was held by another process '
81
+ '(attempt $n of $tries); this step runs again in $pause seconds"',
82
+ " n=$((n + 1))",
83
+ ' sleep "$pause"',
84
+ "done",
85
+ ]
86
+
87
+
88
+ def settle_commands(runtime_commands: list[str] | None = None) -> list[str]:
89
+ """The inline commands of a bake's first provisioner: wait for the
90
+ machine's own first-boot script, then for whatever the runtime says acts
91
+ on a new machine, then write the step runner."""
92
+ cmds = [
93
+ "# before any package work: the machine's own first-boot script has ended (bounded)",
94
+ f"if command -v cloud-init >/dev/null 2>&1; then timeout {BOOT_WAIT_SECONDS} cloud-init status --wait "
95
+ ">/dev/null 2>&1 || true; fi",
96
+ ]
97
+ cmds += list(runtime_commands or [])
98
+ cmds += ["# the step runner: a step that fails on a held package database is run again (never in the image: "
99
+ "/run is a tmpfs)",
100
+ f"sudo tee {STEP_RUNNER} >/dev/null <<'CSIS_STEP'"]
101
+ cmds += step_runner_lines()
102
+ cmds += ["CSIS_STEP", f"sudo chmod 0755 {STEP_RUNNER}"]
103
+ return cmds