offbox 0.1.0__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 (72) hide show
  1. offbox/__init__.py +2 -0
  2. offbox/__main__.py +4 -0
  3. offbox/_data/host/cloud-init.yaml.j2 +15 -0
  4. offbox/_data/host/host-init.sh +230 -0
  5. offbox/_data/images/universal/Dockerfile +101 -0
  6. offbox/_data/images/universal/offbox-init +75 -0
  7. offbox/_data/images/universal/offbox-run +131 -0
  8. offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
  9. offbox/_data/providers/gcp/main.tf +67 -0
  10. offbox/_data/providers/gcp/outputs.tf +7 -0
  11. offbox/_data/providers/gcp/provider.toml +9 -0
  12. offbox/_data/providers/gcp/variables.tf +33 -0
  13. offbox/_data/providers/gcp/versions.tf +12 -0
  14. offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
  15. offbox/_data/providers/hetzner/main.tf +32 -0
  16. offbox/_data/providers/hetzner/outputs.tf +7 -0
  17. offbox/_data/providers/hetzner/provider.toml +9 -0
  18. offbox/_data/providers/hetzner/variables.tf +28 -0
  19. offbox/_data/providers/hetzner/versions.tf +12 -0
  20. offbox/_data/providers/ssh/provider.toml +4 -0
  21. offbox/cli/__init__.py +117 -0
  22. offbox/cli/config.py +39 -0
  23. offbox/cli/env.py +109 -0
  24. offbox/cli/exec.py +50 -0
  25. offbox/cli/host.py +313 -0
  26. offbox/cli/misc.py +132 -0
  27. offbox/cli/ports.py +44 -0
  28. offbox/config.py +189 -0
  29. offbox/console.py +63 -0
  30. offbox/doctor.py +174 -0
  31. offbox/drivers/__init__.py +0 -0
  32. offbox/drivers/base.py +40 -0
  33. offbox/drivers/none.py +114 -0
  34. offbox/drivers/tofu.py +59 -0
  35. offbox/errors.py +45 -0
  36. offbox/execute.py +772 -0
  37. offbox/host/__init__.py +89 -0
  38. offbox/host/activity.py +58 -0
  39. offbox/host/auto.py +322 -0
  40. offbox/host/cloudinit.py +44 -0
  41. offbox/host/create.py +198 -0
  42. offbox/host/destroy.py +151 -0
  43. offbox/host/init.py +196 -0
  44. offbox/host/ready.py +106 -0
  45. offbox/host/timer.py +227 -0
  46. offbox/host/usage.py +123 -0
  47. offbox/host/warnings.py +61 -0
  48. offbox/locking.py +27 -0
  49. offbox/logging.py +154 -0
  50. offbox/logs.py +135 -0
  51. offbox/naming.py +67 -0
  52. offbox/paths.py +55 -0
  53. offbox/ports.py +610 -0
  54. offbox/project.py +187 -0
  55. offbox/providers.py +117 -0
  56. offbox/py.typed +0 -0
  57. offbox/runner.py +291 -0
  58. offbox/runtime.py +887 -0
  59. offbox/setup.py +181 -0
  60. offbox/share.py +286 -0
  61. offbox/ssh.py +179 -0
  62. offbox/state.py +157 -0
  63. offbox/status.py +378 -0
  64. offbox/sync.py +380 -0
  65. offbox/timings.py +69 -0
  66. offbox/tools.py +227 -0
  67. offbox/uninstall.py +114 -0
  68. offbox-0.1.0.dist-info/METADATA +140 -0
  69. offbox-0.1.0.dist-info/RECORD +72 -0
  70. offbox-0.1.0.dist-info/WHEEL +4 -0
  71. offbox-0.1.0.dist-info/entry_points.txt +2 -0
  72. offbox-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,89 @@
1
+ import fcntl
2
+ from collections.abc import Generator
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+
6
+ from offbox.config import UserConfig, default_host, load_config
7
+ from offbox.errors import PreconditionError
8
+ from offbox.host.ready import Clock, SystemClock
9
+ from offbox.locking import acquire_lock
10
+ from offbox.paths import Paths
11
+ from offbox.providers import validate_name
12
+ from offbox.runner import Runner
13
+ from offbox.state import HostState, StateStore
14
+
15
+
16
+ def select_host(paths: Paths, name: str | None = None) -> HostState:
17
+ state = StateStore(paths.state).read()
18
+ selected = name or default_host(load_config(paths.user_config, UserConfig), state.default_host)
19
+ if selected is None:
20
+ raise PreconditionError("No default host", "Run offbox setup, or offbox host use NAME.")
21
+ validate_name(selected)
22
+ if selected not in state.hosts:
23
+ raise PreconditionError(f"Unknown host: {selected}", "Run offbox host list or create this host.")
24
+ return state.hosts[selected]
25
+
26
+
27
+ def public_key(runner: Runner, key: Path) -> str:
28
+ derived = runner.run(["ssh-keygen", "-y", "-P", "", "-f", str(key)], check=False, timeout=5)
29
+ if derived.returncode == 0:
30
+ content = derived.stdout.strip()
31
+ sidecar = key.with_name(key.name + ".pub")
32
+ try:
33
+ if sidecar.exists() and sidecar.read_text(encoding="utf-8").split()[:2] != content.split()[:2]:
34
+ raise PreconditionError(
35
+ "SSH public key sidecar does not match the private key", f"Regenerate {sidecar} with ssh-keygen -y -f {key}."
36
+ )
37
+ except (OSError, UnicodeError) as exc:
38
+ raise PreconditionError(f"Cannot read {sidecar}", "Correct the SSH public key file permissions and encoding.") from exc
39
+ else:
40
+ path = key.with_name(key.name + ".pub")
41
+ if not path.is_file():
42
+ raise PreconditionError(f"SSH key is unavailable: {key}", f"Generate the key or load it with ssh-add {key}.")
43
+ actual = runner.run(["ssh-keygen", "-lf", str(path)], check=False, timeout=5)
44
+ expected = runner.run(["ssh-keygen", "-lf", str(key)], check=False, timeout=5) if key.is_file() else actual
45
+ agent = runner.run(["ssh-add", "-l"], check=False, timeout=5)
46
+ expected_fields, actual_fields = expected.stdout.split(), actual.stdout.split()
47
+ if (
48
+ expected.returncode
49
+ or actual.returncode
50
+ or agent.returncode
51
+ or len(expected_fields) < 2
52
+ or len(actual_fields) < 2
53
+ or expected_fields[1] != actual_fields[1]
54
+ or expected_fields[1] not in agent.stdout
55
+ ):
56
+ raise PreconditionError(
57
+ "SSH public key does not match an available identity", f"Regenerate {path} and load the matching key with ssh-add {key}."
58
+ )
59
+ try:
60
+ content = path.read_text(encoding="utf-8").strip()
61
+ except (OSError, UnicodeError) as exc:
62
+ raise PreconditionError(f"Cannot read public key for {key}", "Generate its .pub file with ssh-keygen -y.") from exc
63
+ fields = content.split()
64
+ if len(content.splitlines()) != 1 or len(fields) < 2 or not fields[0].startswith(("ssh-", "ecdsa-", "sk-")):
65
+ raise PreconditionError("Invalid SSH public key", "Generate a valid SSH key with ssh-keygen.")
66
+ return content
67
+
68
+
69
+ @contextmanager
70
+ def host_operation(paths: Paths, name: str, *, wait_timeout: float = 0, clock: Clock | None = None) -> Generator[None]:
71
+ validate_name(name)
72
+ directory = paths.state / "host-locks"
73
+ try:
74
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
75
+ handle = (directory / f"{name}.lock").open("a", encoding="utf-8")
76
+ except OSError as exc:
77
+ raise PreconditionError("Cannot open host operation lock", "Check state directory permissions.") from exc
78
+ with handle:
79
+ acquire_lock(
80
+ handle.fileno(),
81
+ timeout=wait_timeout,
82
+ clock=clock or SystemClock(),
83
+ message=f"A lifecycle operation is already running for {name}",
84
+ fix="Wait for that operation to finish, then retry.",
85
+ )
86
+ try:
87
+ yield
88
+ finally:
89
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@@ -0,0 +1,58 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from offbox.errors import PreconditionError
8
+ from offbox.paths import Paths
9
+ from offbox.state import EnvState
10
+
11
+ logger = logging.getLogger(__name__)
12
+ _ENV_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,36}")
13
+
14
+
15
+ def activity_path(paths: Paths, env: str) -> Path:
16
+ if _ENV_NAME.fullmatch(env) is None:
17
+ raise PreconditionError("Invalid environment name", "Repair the environment state.")
18
+ return paths.state / "activity" / env
19
+
20
+
21
+ def touch(paths: Paths, env: str, now: datetime) -> None:
22
+ """Record activity without locks; a failure is logged and never stops the command."""
23
+ path = activity_path(paths, env)
24
+ try:
25
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
26
+ path.touch(mode=0o600)
27
+ stamp = now.timestamp()
28
+ os.utime(path, (stamp, stamp))
29
+ except OSError:
30
+ logger.warning("cannot record activity for %s", env, exc_info=True)
31
+
32
+
33
+ def last_activity(paths: Paths, env: EnvState) -> datetime:
34
+ try:
35
+ return max(env.created_at, datetime.fromtimestamp(activity_path(paths, env.name).stat().st_mtime, UTC))
36
+ except OSError:
37
+ return env.created_at
38
+
39
+
40
+ def forget(paths: Paths, env: str) -> None:
41
+ try:
42
+ activity_path(paths, env).unlink(missing_ok=True)
43
+ except OSError:
44
+ logger.warning("cannot remove activity record for %s", env, exc_info=True)
45
+
46
+
47
+ def worktree_gone(root: Path) -> bool:
48
+ """True only for a removed worktree: the path is missing while its parent exists. Unreadable or unmounted paths count as present."""
49
+ try:
50
+ root.stat()
51
+ return False
52
+ except (FileNotFoundError, NotADirectoryError):
53
+ try:
54
+ return root.parent.is_dir()
55
+ except OSError:
56
+ return False
57
+ except OSError:
58
+ return False
offbox/host/auto.py ADDED
@@ -0,0 +1,322 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from contextlib import ExitStack
4
+ from datetime import datetime, timedelta
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from pydantic import Field, ValidationError
9
+
10
+ from offbox import console
11
+ from offbox.config import Model, UserConfig, duration_seconds, load_config
12
+ from offbox.errors import OffboxError, PreconditionError
13
+ from offbox.host import host_operation
14
+ from offbox.host.activity import forget, last_activity, worktree_gone
15
+ from offbox.host.destroy import destroy_host_locked
16
+ from offbox.host.ready import Clock, SystemClock, tcp_probe
17
+ from offbox.host.usage import WallClock, describe, exclusive_usage, holders, utc_now
18
+ from offbox.paths import Paths
19
+ from offbox.providers import validate_name
20
+ from offbox.runner import Runner
21
+ from offbox.ssh import write_private
22
+ from offbox.state import HostState, State, StateStore
23
+
24
+ logger = logging.getLogger(__name__)
25
+ CREATE_WAIT_SECONDS = 900
26
+ RELEASE_WAIT_SECONDS = 60
27
+ CREATING_GRACE = timedelta(minutes=30)
28
+ REAP_LOCK_WAIT_SECONDS = 5
29
+ Action = Literal["kept", "destroy", "destroyed", "skipped", "failed"]
30
+
31
+
32
+ class ReapOutcome(Model):
33
+ name: str
34
+ action: Action
35
+ reason: str
36
+ lost_environments: list[str] = Field(default_factory=list[str])
37
+ error: str | None = None
38
+
39
+
40
+ class ReapReport(Model):
41
+ dry_run: bool = False
42
+ hosts: list[ReapOutcome] = Field(default_factory=list[ReapOutcome])
43
+
44
+ @property
45
+ def exit_code(self) -> int:
46
+ return 1 if any(outcome.action == "failed" for outcome in self.hosts) else 0
47
+
48
+
49
+ class ReapRecord(Model):
50
+ finished_at: datetime
51
+ source: Literal["timer", "manual"]
52
+ report: ReapReport
53
+
54
+
55
+ class ReapHistory(Model):
56
+ timer: ReapRecord | None = None
57
+ manual: ReapRecord | None = None
58
+
59
+
60
+ class Decision(Model):
61
+ destroy: bool
62
+ reason: str
63
+
64
+
65
+ def _record_path(paths: Paths) -> Path:
66
+ return paths.state / "reap.json"
67
+
68
+
69
+ def read_history(paths: Paths) -> ReapHistory:
70
+ try:
71
+ return ReapHistory.model_validate_json(_record_path(paths).read_text())
72
+ except FileNotFoundError:
73
+ return ReapHistory()
74
+ except (OSError, ValidationError):
75
+ logger.warning("ignoring unreadable reap record", exc_info=True)
76
+ return ReapHistory()
77
+
78
+
79
+ def timer_status(paths: Paths) -> tuple[datetime | None, str | None]:
80
+ """When the timer last reaped and the first failure it reported, for health checks."""
81
+ record = read_history(paths).timer
82
+ if record is None:
83
+ return None, None
84
+ failed = next((outcome for outcome in record.report.hosts if outcome.action == "failed"), None)
85
+ return record.finished_at, None if failed is None else f"{failed.name}: {failed.error}"
86
+
87
+
88
+ def lost_environment_hint(paths: Paths, env: str) -> str | None:
89
+ """How a missing environment record was lost, when the last reap removed it."""
90
+ history = read_history(paths)
91
+ records = sorted(
92
+ (record for record in (history.timer, history.manual) if record is not None), key=lambda item: item.finished_at, reverse=True
93
+ )
94
+ for record in records:
95
+ for outcome in record.report.hosts:
96
+ if outcome.action == "destroyed" and env in outcome.lost_environments:
97
+ stamp = record.finished_at.astimezone().strftime("%Y-%m-%d %H:%M")
98
+ return f"host {outcome.name} was reaped at {stamp} ({outcome.reason}); its Docker data is gone"
99
+ return None
100
+
101
+
102
+ def _format(seconds: float) -> str:
103
+ minutes = int(seconds // 60)
104
+ return f"{minutes // 60}h{minutes % 60:02d}m" if minutes >= 60 else f"{minutes}m"
105
+
106
+
107
+ def host_activity(paths: Paths, state: State, host: HostState) -> datetime:
108
+ return max([host.created_at, *(last_activity(paths, env) for env in state.envs.values() if env.host == host.name)])
109
+
110
+
111
+ def reap_decision(host: HostState, activity: datetime, now: datetime, *, operation_free: bool, empty: bool = False) -> Decision:
112
+ """Whether an idle, empty or abandoned auto-managed host should be destroyed now; running commands are guarded by the usage lock."""
113
+ if not host.auto:
114
+ return Decision(destroy=False, reason="manual host")
115
+ if host.phase == "ready" and empty:
116
+ return Decision(destroy=True, reason="no environments use it")
117
+ if host.phase != "ready":
118
+ if not operation_free:
119
+ return Decision(destroy=False, reason="creation in progress")
120
+ if now - host.created_at > CREATING_GRACE:
121
+ return Decision(destroy=True, reason="creation did not finish")
122
+ return Decision(destroy=False, reason="creation started recently")
123
+ if host.idle_seconds is None:
124
+ return Decision(destroy=False, reason="idle reaping is off")
125
+ idle = (now - activity).total_seconds()
126
+ if idle > host.idle_seconds:
127
+ return Decision(destroy=True, reason=f"idle for {_format(idle)}")
128
+ return Decision(destroy=False, reason=f"last used {_format(max(idle, 0))} ago; reaped after {_format(host.idle_seconds)} idle")
129
+
130
+
131
+ def _destroy(paths: Paths, runner: Runner, host: HostState, *, executable: Path | None) -> list[str]:
132
+ state = StateStore(paths.state).read()
133
+ lost = sorted(name for name, env in state.envs.items() if env.host == host.name)
134
+ destroy_host_locked(paths, runner, host.name, executable=executable)
135
+ for name in lost:
136
+ forget(paths, name)
137
+ return lost
138
+
139
+
140
+ def ensure_host(
141
+ paths: Paths,
142
+ runner: Runner,
143
+ name: str,
144
+ *,
145
+ clock: Clock | None = None,
146
+ executable: Path | None = None,
147
+ probe: Callable[[str, int, float], bool] = tcp_probe,
148
+ ssh_config: Path | None = None,
149
+ timer_installed: Callable[[Paths], bool] | None = None,
150
+ ) -> HostState:
151
+ """Return the ready host NAME, creating it when its user preset is auto-managed; parallel callers create it once."""
152
+ validate_name(name)
153
+ host = StateStore(paths.state).read().hosts.get(name)
154
+ if host is not None and host.phase == "ready":
155
+ return host
156
+ preset = load_config(paths.user_config, UserConfig).hosts.get(name)
157
+ if host is None and (preset is None or not preset.auto):
158
+ raise PreconditionError(f"Unknown host: {name}", "Run offbox host list or create this host.")
159
+ with host_operation(paths, name, wait_timeout=CREATE_WAIT_SECONDS, clock=clock or SystemClock()):
160
+ host = StateStore(paths.state).read().hosts.get(name)
161
+ if host is not None and host.phase == "ready":
162
+ return host
163
+ if host is not None:
164
+ raise PreconditionError(f"Host {name} did not finish creating", f"Run offbox host destroy {name}, then retry.")
165
+ if preset is None or not preset.auto:
166
+ raise PreconditionError(f"Unknown host: {name}", "Run offbox host list or create this host.")
167
+ idle = duration_seconds(preset.idle)
168
+ limit = (
169
+ f"destroyed after {_format(idle)} idle or when its last environment is removed"
170
+ if idle
171
+ else "destroyed when its last environment is removed"
172
+ )
173
+ console.warn(f"Creating auto-managed host {name}: billable until destroyed; {limit}")
174
+ if idle and timer_installed is not None:
175
+ if not timer_installed(paths):
176
+ console.info("Idle reaping needs the timer: offbox host reap --install-timer")
177
+ else:
178
+ from offbox.host.timer import check_installed_credentials
179
+
180
+ problem = check_installed_credentials(paths)
181
+ if problem is not None:
182
+ console.warn(f"The idle reaper cannot destroy {name}: {problem}")
183
+ from offbox.host.create import CreateOptions, create_host_locked
184
+
185
+ return create_host_locked(
186
+ paths,
187
+ runner,
188
+ CreateOptions(name=name, auto=True, idle_seconds=idle),
189
+ executable=executable,
190
+ clock=clock,
191
+ probe=probe,
192
+ ssh_config=ssh_config,
193
+ )
194
+
195
+
196
+ def release_if_unused(
197
+ paths: Paths,
198
+ runner: Runner,
199
+ name: str,
200
+ *,
201
+ clock: Clock | None = None,
202
+ executable: Path | None = None,
203
+ timer_installed: Callable[[Paths], bool] | None = None,
204
+ ) -> bool:
205
+ """Destroy an auto-managed host whose last environment is gone; called after rm released the host lock."""
206
+ store = StateStore(paths.state)
207
+ host = store.read().hosts.get(name)
208
+ if host is None or not host.auto:
209
+ return False
210
+ if any(env.host == name and not worktree_gone(env.worktree_path) for env in store.read().envs.values()):
211
+ return False
212
+ timer = clock or SystemClock()
213
+ with exclusive_usage(paths, name, wait=RELEASE_WAIT_SECONDS, clock=timer) as free:
214
+ if not free:
215
+ fix = f"run offbox host reap {name} once they finish"
216
+ if timer_installed is not None and not timer_installed(paths):
217
+ fix += ", or install the idle reaper with offbox host reap --install-timer"
218
+ console.warn(f"Host {name} is still in use by: {describe(holders(paths, name))}. Fix: {fix}.")
219
+ return False
220
+ with host_operation(paths, name, wait_timeout=RELEASE_WAIT_SECONDS, clock=timer):
221
+ state = store.read()
222
+ host = state.hosts.get(name)
223
+ if host is None or not host.auto:
224
+ return False
225
+ if any(env.host == name and not worktree_gone(env.worktree_path) for env in state.envs.values()):
226
+ return False
227
+ console.step(f"Destroying auto-managed host {name}: its last environment was removed")
228
+ lost = _destroy(paths, runner, host, executable=executable)
229
+ previous = read_history(paths).manual
230
+ earlier = [outcome for outcome in previous.report.hosts if outcome.name != name] if previous is not None else []
231
+ outcome = ReapOutcome(name=name, action="destroyed", reason="last environment removed", lost_environments=lost)
232
+ _record(paths, ReapReport(hosts=[*earlier, outcome]), "manual", utc_now())
233
+ return True
234
+
235
+
236
+ def reap(
237
+ paths: Paths,
238
+ runner: Runner,
239
+ name: str | None = None,
240
+ *,
241
+ dry_run: bool = False,
242
+ source: Literal["timer", "manual"] = "manual",
243
+ clock: Clock | None = None,
244
+ now: WallClock = utc_now,
245
+ executable: Path | None = None,
246
+ ) -> ReapReport:
247
+ store = StateStore(paths.state)
248
+ state = store.read()
249
+ if name is not None:
250
+ validate_name(name)
251
+ if name not in state.hosts:
252
+ raise PreconditionError(f"Unknown host: {name}", "Run offbox host list.")
253
+ names = [name]
254
+ else:
255
+ names = sorted(host.name for host in state.hosts.values() if host.auto)
256
+ report = ReapReport(dry_run=dry_run)
257
+ timer = clock or SystemClock()
258
+ for current in names:
259
+ try:
260
+ report.hosts.append(_reap_one(paths, runner, current, dry_run=dry_run, clock=timer, now=now, executable=executable))
261
+ except OffboxError as exc:
262
+ logger.exception("reaping %s failed", current)
263
+ report.hosts.append(ReapOutcome(name=current, action="failed", reason="destroy failed", error=f"{exc.message} Fix: {exc.fix}"))
264
+ if not dry_run:
265
+ _record(paths, report, source, now())
266
+ return report
267
+
268
+
269
+ def _record(paths: Paths, report: ReapReport, source: Literal["timer", "manual"], finished_at: datetime) -> None:
270
+ history = read_history(paths).model_copy(update={source: ReapRecord(finished_at=finished_at, source=source, report=report)})
271
+ write_private(_record_path(paths), history.model_dump_json(indent=2) + "\n")
272
+
273
+
274
+ def clear_lost(paths: Paths, env: str) -> None:
275
+ """Forget that ENV was lost to a reap once it is registered again or removed, so later hints stay true."""
276
+ history = read_history(paths)
277
+ changed = False
278
+ for record in (history.timer, history.manual):
279
+ if record is None:
280
+ continue
281
+ for outcome in record.report.hosts:
282
+ if env in outcome.lost_environments:
283
+ outcome.lost_environments.remove(env)
284
+ changed = True
285
+ if changed:
286
+ write_private(_record_path(paths), history.model_dump_json(indent=2) + "\n")
287
+
288
+
289
+ def _reap_one(
290
+ paths: Paths, runner: Runner, name: str, *, dry_run: bool, clock: Clock, now: WallClock, executable: Path | None
291
+ ) -> ReapOutcome:
292
+ store = StateStore(paths.state)
293
+ host = store.read().hosts.get(name)
294
+ if host is None:
295
+ return ReapOutcome(name=name, action="skipped", reason="host no longer registered")
296
+ if not host.auto:
297
+ return ReapOutcome(name=name, action="skipped", reason="manual host")
298
+ with ExitStack() as stack:
299
+ if not stack.enter_context(exclusive_usage(paths, name, clock=clock)):
300
+ return ReapOutcome(name=name, action="skipped", reason=f"in use by {describe(holders(paths, name))}")
301
+ try:
302
+ stack.enter_context(host_operation(paths, name, wait_timeout=REAP_LOCK_WAIT_SECONDS, clock=clock))
303
+ operation_free = True
304
+ except PreconditionError:
305
+ operation_free = False
306
+ state = store.read()
307
+ host = state.hosts.get(name)
308
+ if host is None:
309
+ return ReapOutcome(name=name, action="skipped", reason="host no longer registered")
310
+ if not operation_free and host.phase == "ready":
311
+ return ReapOutcome(name=name, action="skipped", reason="another host operation is running")
312
+ empty = not any(env.host == name and not worktree_gone(env.worktree_path) for env in state.envs.values())
313
+ decision = reap_decision(host, host_activity(paths, state, host), now(), operation_free=operation_free, empty=empty)
314
+ lost = sorted(env.name for env in state.envs.values() if env.host == name)
315
+ if not decision.destroy:
316
+ return ReapOutcome(name=name, action="kept", reason=decision.reason)
317
+ if dry_run:
318
+ return ReapOutcome(name=name, action="destroy", reason=decision.reason, lost_environments=lost)
319
+ console.step(f"Reaping auto-managed host {name}: {decision.reason}")
320
+ lost = _destroy(paths, runner, host, executable=executable)
321
+ return ReapOutcome(name=name, action="destroyed", reason=decision.reason, lost_environments=lost)
322
+ return ReapOutcome(name=name, action="skipped", reason="unreachable")
@@ -0,0 +1,44 @@
1
+ from importlib.resources import files
2
+ from pathlib import Path
3
+
4
+ from jinja2 import Environment, StrictUndefined
5
+
6
+ from offbox import DEFAULT_IMAGE, __version__
7
+ from offbox.errors import PreconditionError
8
+ from offbox.logging import register_secrets
9
+ from offbox.runner import Runner
10
+
11
+
12
+ def bootstrap_script() -> str:
13
+ return files("offbox").joinpath("_data", "host", "host-init.sh").read_text(encoding="utf-8")
14
+
15
+
16
+ def render_cloud_init(public_key: str, private_host_key: str, public_host_key: str, *, script: str | None = None) -> str:
17
+ template = files("offbox").joinpath("_data", "host", "cloud-init.yaml.j2").read_text(encoding="utf-8")
18
+ register_secrets({"OFFBOX_HOST_PRIVATE_KEY": private_host_key})
19
+ return (
20
+ Environment(undefined=StrictUndefined, keep_trailing_newline=True)
21
+ .from_string(template)
22
+ .render(
23
+ private_host_key=private_host_key,
24
+ public_host_key=public_host_key,
25
+ script=bootstrap_script() if script is None else script,
26
+ bootstrap_argv=[
27
+ "env",
28
+ "OFFBOX_HARDEN=1",
29
+ f"OFFBOX_VERSION={__version__}",
30
+ "/usr/local/sbin/offbox-host-init",
31
+ DEFAULT_IMAGE,
32
+ public_key,
33
+ ],
34
+ )
35
+ )
36
+
37
+
38
+ def generate_host_key(runner: Runner, path: Path) -> tuple[str, str]:
39
+ runner.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-C", "offbox-host", "-f", str(path)])
40
+ try:
41
+ path.chmod(0o600)
42
+ return path.read_text(encoding="utf-8"), path.with_name(path.name + ".pub").read_text(encoding="utf-8").strip()
43
+ except (OSError, UnicodeError) as exc:
44
+ raise PreconditionError("Cannot read generated SSH host key", "Check state directory permissions and ssh-keygen.") from exc