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.
- offbox/__init__.py +2 -0
- offbox/__main__.py +4 -0
- offbox/_data/host/cloud-init.yaml.j2 +15 -0
- offbox/_data/host/host-init.sh +230 -0
- offbox/_data/images/universal/Dockerfile +101 -0
- offbox/_data/images/universal/offbox-init +75 -0
- offbox/_data/images/universal/offbox-run +131 -0
- offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
- offbox/_data/providers/gcp/main.tf +67 -0
- offbox/_data/providers/gcp/outputs.tf +7 -0
- offbox/_data/providers/gcp/provider.toml +9 -0
- offbox/_data/providers/gcp/variables.tf +33 -0
- offbox/_data/providers/gcp/versions.tf +12 -0
- offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
- offbox/_data/providers/hetzner/main.tf +32 -0
- offbox/_data/providers/hetzner/outputs.tf +7 -0
- offbox/_data/providers/hetzner/provider.toml +9 -0
- offbox/_data/providers/hetzner/variables.tf +28 -0
- offbox/_data/providers/hetzner/versions.tf +12 -0
- offbox/_data/providers/ssh/provider.toml +4 -0
- offbox/cli/__init__.py +117 -0
- offbox/cli/config.py +39 -0
- offbox/cli/env.py +109 -0
- offbox/cli/exec.py +50 -0
- offbox/cli/host.py +313 -0
- offbox/cli/misc.py +132 -0
- offbox/cli/ports.py +44 -0
- offbox/config.py +189 -0
- offbox/console.py +63 -0
- offbox/doctor.py +174 -0
- offbox/drivers/__init__.py +0 -0
- offbox/drivers/base.py +40 -0
- offbox/drivers/none.py +114 -0
- offbox/drivers/tofu.py +59 -0
- offbox/errors.py +45 -0
- offbox/execute.py +772 -0
- offbox/host/__init__.py +89 -0
- offbox/host/activity.py +58 -0
- offbox/host/auto.py +322 -0
- offbox/host/cloudinit.py +44 -0
- offbox/host/create.py +198 -0
- offbox/host/destroy.py +151 -0
- offbox/host/init.py +196 -0
- offbox/host/ready.py +106 -0
- offbox/host/timer.py +227 -0
- offbox/host/usage.py +123 -0
- offbox/host/warnings.py +61 -0
- offbox/locking.py +27 -0
- offbox/logging.py +154 -0
- offbox/logs.py +135 -0
- offbox/naming.py +67 -0
- offbox/paths.py +55 -0
- offbox/ports.py +610 -0
- offbox/project.py +187 -0
- offbox/providers.py +117 -0
- offbox/py.typed +0 -0
- offbox/runner.py +291 -0
- offbox/runtime.py +887 -0
- offbox/setup.py +181 -0
- offbox/share.py +286 -0
- offbox/ssh.py +179 -0
- offbox/state.py +157 -0
- offbox/status.py +378 -0
- offbox/sync.py +380 -0
- offbox/timings.py +69 -0
- offbox/tools.py +227 -0
- offbox/uninstall.py +114 -0
- offbox-0.1.0.dist-info/METADATA +140 -0
- offbox-0.1.0.dist-info/RECORD +72 -0
- offbox-0.1.0.dist-info/WHEEL +4 -0
- offbox-0.1.0.dist-info/entry_points.txt +2 -0
- offbox-0.1.0.dist-info/licenses/LICENSE +21 -0
offbox/host/__init__.py
ADDED
|
@@ -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)
|
offbox/host/activity.py
ADDED
|
@@ -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")
|
offbox/host/cloudinit.py
ADDED
|
@@ -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
|