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.
- cs_image_system/base/__init__.py +3 -0
- cs_image_system/base/accounts_reconcile.py +107 -0
- cs_image_system/base/alias_pool.py +258 -0
- cs_image_system/base/ansible_launch.py +96 -0
- cs_image_system/base/bake_steps.py +103 -0
- cs_image_system/base/bake_user.py +112 -0
- cs_image_system/base/basic/abstract_plugin_metadata.py +56 -0
- cs_image_system/base/basic/abstract_version_checker.py +71 -0
- cs_image_system/base/basic/asset.py +246 -0
- cs_image_system/base/basic/builder_base.py +209 -0
- cs_image_system/base/basic/builder_base_cloud.py +18 -0
- cs_image_system/base/basic/builder_base_container.py +17 -0
- cs_image_system/base/basic/builder_base_group.py +316 -0
- cs_image_system/base/basic/builder_base_image.py +152 -0
- cs_image_system/base/basic/builder_base_instance.py +44 -0
- cs_image_system/base/basic/builder_base_mod.py +48 -0
- cs_image_system/base/basic/builder_base_os.py +319 -0
- cs_image_system/base/basic/builder_base_runtime.py +362 -0
- cs_image_system/base/basic/builder_base_state.py +19 -0
- cs_image_system/base/basic/builder_base_storage.py +114 -0
- cs_image_system/base/basic/builder_base_user.py +95 -0
- cs_image_system/base/bootstrap/__init__.py +36 -0
- cs_image_system/base/bootstrap/answers.py +50 -0
- cs_image_system/base/bootstrap/facts.py +192 -0
- cs_image_system/base/bootstrap/generate.py +180 -0
- cs_image_system/base/bootstrap/github.py +126 -0
- cs_image_system/base/bootstrap/hcl.py +238 -0
- cs_image_system/base/bootstrap/interview.py +91 -0
- cs_image_system/base/bootstrap/questions.py +165 -0
- cs_image_system/base/bootstrap/sections.py +36 -0
- cs_image_system/base/capabilities.py +231 -0
- cs_image_system/base/commands/__init__.py +23 -0
- cs_image_system/base/commands/dispose.py +170 -0
- cs_image_system/base/commands/emission.py +292 -0
- cs_image_system/base/commands/finalization.py +41 -0
- cs_image_system/base/commands/gate.py +95 -0
- cs_image_system/base/commands/gen_groups.py +60 -0
- cs_image_system/base/commands/gen_images.py +64 -0
- cs_image_system/base/commands/gen_instances.py +59 -0
- cs_image_system/base/commands/gen_storages.py +47 -0
- cs_image_system/base/commands/gen_users.py +52 -0
- cs_image_system/base/commands/identity_gids.py +48 -0
- cs_image_system/base/commands/load_failures.py +101 -0
- cs_image_system/base/commands/login_proof.py +172 -0
- cs_image_system/base/commands/preflight.py +310 -0
- cs_image_system/base/commands/relabel.py +197 -0
- cs_image_system/base/commands/resolve.py +182 -0
- cs_image_system/base/commands/restamp.py +96 -0
- cs_image_system/base/commands/run_lifecycles.py +732 -0
- cs_image_system/base/commands/run_scope.py +111 -0
- cs_image_system/base/commands/runtime_facts.py +208 -0
- cs_image_system/base/commands/state_migration.py +253 -0
- cs_image_system/base/commands/unmount.py +101 -0
- cs_image_system/base/commands/upgrade.py +83 -0
- cs_image_system/base/commands/validate.py +733 -0
- cs_image_system/base/commands/verify_instance.py +190 -0
- cs_image_system/base/commands/workload_token.py +164 -0
- cs_image_system/base/constants.py +204 -0
- cs_image_system/base/encryption.py +587 -0
- cs_image_system/base/generations.py +185 -0
- cs_image_system/base/global_context.py +1538 -0
- cs_image_system/base/helpers/async_utils.py +74 -0
- cs_image_system/base/helpers/config.py +18 -0
- cs_image_system/base/helpers/field_helpers.py +81 -0
- cs_image_system/base/helpers/field_kinds.py +94 -0
- cs_image_system/base/helpers/plugin_config.py +17 -0
- cs_image_system/base/helpers/resolution_stages.py +39 -0
- cs_image_system/base/helpers/yaml_process.py +3 -0
- cs_image_system/base/identity_attributes.py +206 -0
- cs_image_system/base/image_tests.py +275 -0
- cs_image_system/base/init.py +30 -0
- cs_image_system/base/launch_enrollment.py +67 -0
- cs_image_system/base/launch_params.py +677 -0
- cs_image_system/base/lifecycle.py +69 -0
- cs_image_system/base/lifecycles.py +198 -0
- cs_image_system/base/lineage.py +729 -0
- cs_image_system/base/loader.py +61 -0
- cs_image_system/base/materialize.py +288 -0
- cs_image_system/base/meta_state.py +707 -0
- cs_image_system/base/mod_tests.py +396 -0
- cs_image_system/base/models/base_image.py +407 -0
- cs_image_system/base/models/builder_model.py +293 -0
- cs_image_system/base/models/cloud.py +22 -0
- cs_image_system/base/models/cloud_builder.py +37 -0
- cs_image_system/base/models/container.py +23 -0
- cs_image_system/base/models/container_builder.py +30 -0
- cs_image_system/base/models/credentials.py +50 -0
- cs_image_system/base/models/encryption_config.py +19 -0
- cs_image_system/base/models/executable.py +194 -0
- cs_image_system/base/models/executable_adds.py +37 -0
- cs_image_system/base/models/group.py +144 -0
- cs_image_system/base/models/group_builder.py +39 -0
- cs_image_system/base/models/ia_config.py +133 -0
- cs_image_system/base/models/image.py +454 -0
- cs_image_system/base/models/image_builder_model.py +25 -0
- cs_image_system/base/models/instance.py +219 -0
- cs_image_system/base/models/instance_builder.py +26 -0
- cs_image_system/base/models/mod_builder.py +21 -0
- cs_image_system/base/models/model_config.py +21 -0
- cs_image_system/base/models/moditem_type.py +38 -0
- cs_image_system/base/models/module_variables.py +43 -0
- cs_image_system/base/models/os_builder_model.py +168 -0
- cs_image_system/base/models/os_builder_runtime_config.py +211 -0
- cs_image_system/base/models/provider_specific_image.py +198 -0
- cs_image_system/base/models/public_safe_config.py +23 -0
- cs_image_system/base/models/root_item.py +69 -0
- cs_image_system/base/models/runtime.py +166 -0
- cs_image_system/base/models/runtime_builder_item.py +13 -0
- cs_image_system/base/models/state_builder.py +19 -0
- cs_image_system/base/models/storage.py +148 -0
- cs_image_system/base/models/storage_builder.py +26 -0
- cs_image_system/base/models/update_policy.py +105 -0
- cs_image_system/base/models/user.py +239 -0
- cs_image_system/base/models/user_builder.py +58 -0
- cs_image_system/base/orchestrator.py +775 -0
- cs_image_system/base/posix_ids.py +113 -0
- cs_image_system/base/power_state.py +176 -0
- cs_image_system/base/protocols/builder_model_protocol.py +40 -0
- cs_image_system/base/protocols/name_typed_protocol.py +36 -0
- cs_image_system/base/protocols/parent_property_holding_protocol.py +95 -0
- cs_image_system/base/protocols/plugin_metadata.py +56 -0
- cs_image_system/base/protocols/state_management.py +21 -0
- cs_image_system/base/provider_aliases.py +320 -0
- cs_image_system/base/public_safe.py +394 -0
- cs_image_system/base/py.typed +0 -0
- cs_image_system/base/read_models.py +145 -0
- cs_image_system/base/registry.py +296 -0
- cs_image_system/base/release.py +254 -0
- cs_image_system/base/reserved_names.py +121 -0
- cs_image_system/base/retention.py +118 -0
- cs_image_system/base/singleton.py +32 -0
- cs_image_system/base/state_query.py +700 -0
- cs_image_system/base/storage_state.py +190 -0
- cs_image_system/base/template_utils.py +687 -0
- cs_image_system/base/tofu_lock.py +70 -0
- cs_image_system/base/utils.py +263 -0
- cs_image_system/base/v2_validation.py +241 -0
- cs_image_system/base/workload_access.py +67 -0
- cs_image_system_base-0.1.1.dist-info/METADATA +2558 -0
- cs_image_system_base-0.1.1.dist-info/RECORD +143 -0
- cs_image_system_base-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_base-0.1.1.dist-info/entry_points.txt +2 -0
- cs_image_system_base-0.1.1.dist-info/licenses/LICENSE +202 -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
|