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/timer.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from html import escape as _escape
|
|
10
|
+
from html import unescape
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from offbox import console
|
|
14
|
+
from offbox.config import UserConfig, load_config
|
|
15
|
+
from offbox.errors import PreconditionError
|
|
16
|
+
from offbox.paths import Paths
|
|
17
|
+
from offbox.providers import credentials_env, discover_provider
|
|
18
|
+
from offbox.runner import Runner
|
|
19
|
+
from offbox.ssh import write_private
|
|
20
|
+
|
|
21
|
+
INTERVAL_SECONDS = 900
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def escape(value: str) -> str:
|
|
25
|
+
return _escape(value, quote=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
STALE_AFTER = timedelta(minutes=60)
|
|
29
|
+
LAUNCHD_LABEL = "dev.offbox.reap"
|
|
30
|
+
SYSTEMD_UNIT = "offbox-reap"
|
|
31
|
+
_PASSED_THROUGH = ("XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "GOOGLE_PROJECT")
|
|
32
|
+
_TOOLS = ("tofu", "terraform", "ssh", "git", "mutagen")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unit_files(paths: Paths, home: Path, system: str | None = None) -> list[Path]:
|
|
36
|
+
"""Scheduler files: a launchd agent under HOME on macOS, systemd user units beside offbox's XDG config elsewhere."""
|
|
37
|
+
if (system or platform.system()) == "Darwin":
|
|
38
|
+
return [home / "Library/LaunchAgents" / f"{LAUNCHD_LABEL}.plist"]
|
|
39
|
+
base = paths.config.parent / "systemd/user"
|
|
40
|
+
return [base / f"{SYSTEMD_UNIT}.service", base / f"{SYSTEMD_UNIT}.timer"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def installed(paths: Paths, *, home: Path | None = None, system: str | None = None) -> bool:
|
|
44
|
+
return all(path.is_file() for path in unit_files(paths, home or Path.home(), system))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def executable_path() -> Path:
|
|
48
|
+
"""The installed offbox command the timer runs; ephemeral uvx or temporary environments are refused."""
|
|
49
|
+
found = shutil.which("offbox")
|
|
50
|
+
candidate = Path(found).resolve() if found else Path(sys.argv[0]).resolve()
|
|
51
|
+
text = str(candidate)
|
|
52
|
+
temporary = Path(tempfile.gettempdir()).resolve()
|
|
53
|
+
if (
|
|
54
|
+
not candidate.is_file()
|
|
55
|
+
or candidate.is_relative_to(temporary)
|
|
56
|
+
or "/.cache/uv/" in text
|
|
57
|
+
or "/uv/cache/" in text
|
|
58
|
+
or text.startswith("/tmp/")
|
|
59
|
+
):
|
|
60
|
+
raise PreconditionError(
|
|
61
|
+
f"The idle reaper needs an installed offbox, not {candidate}", "Install it with uv tool install offbox, then retry."
|
|
62
|
+
)
|
|
63
|
+
return candidate
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unit_environment(paths: Paths, environ: Mapping[str, str], home: Path) -> dict[str, str]:
|
|
67
|
+
"""The environment a scheduled reap runs with: tool directories and settings locations, never credentials."""
|
|
68
|
+
directories: list[str] = []
|
|
69
|
+
for tool in _TOOLS:
|
|
70
|
+
found = shutil.which(tool, path=environ.get("PATH"))
|
|
71
|
+
if found:
|
|
72
|
+
directories.append(str(Path(found).parent))
|
|
73
|
+
from offbox.tools import cached_mutagen
|
|
74
|
+
|
|
75
|
+
mutagen = cached_mutagen(paths.cache)
|
|
76
|
+
if mutagen is not None:
|
|
77
|
+
directories.append(str(mutagen.parent))
|
|
78
|
+
directories.extend(["/usr/bin", "/bin", "/usr/sbin", "/sbin"])
|
|
79
|
+
environment = {"HOME": str(home), "PATH": ":".join(dict.fromkeys(directories))}
|
|
80
|
+
environment.update({name: environ[name] for name in _PASSED_THROUGH if environ.get(name)})
|
|
81
|
+
return environment
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def preflight(paths: Paths, environment: Mapping[str, str], home: Path) -> None:
|
|
85
|
+
"""Fail unless every auto-managed preset's credentials are readable without the current shell's environment."""
|
|
86
|
+
for name, preset in sorted(load_config(paths.user_config, UserConfig).hosts.items()):
|
|
87
|
+
if not preset.auto:
|
|
88
|
+
continue
|
|
89
|
+
provider = discover_provider(preset.provider, paths)
|
|
90
|
+
try:
|
|
91
|
+
credentials_env(provider.manifest, paths, environment)
|
|
92
|
+
except PreconditionError as exc:
|
|
93
|
+
fix = (
|
|
94
|
+
f"Store the Hetzner token in {paths.credentials} (mode 0600) instead of only exporting HCLOUD_TOKEN."
|
|
95
|
+
if provider.manifest.name == "hetzner"
|
|
96
|
+
else f"Export them in the shell that runs --install-timer, which copies non-secret provider variables into the timer: {exc.fix}"
|
|
97
|
+
)
|
|
98
|
+
raise PreconditionError(f"The idle reaper cannot read credentials for host {name}: {exc.message}", fix) from exc
|
|
99
|
+
if provider.manifest.name == "gcp" and not (home / ".config/gcloud/application_default_credentials.json").is_file():
|
|
100
|
+
raise PreconditionError(
|
|
101
|
+
f"The idle reaper cannot find Google application default credentials for host {name}",
|
|
102
|
+
"Run gcloud auth application-default login, then retry.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_launchd(executable: Path, environment: Mapping[str, str], log: Path) -> str:
|
|
107
|
+
variables = "".join(f"<key>{escape(key)}</key><string>{escape(value)}</string>" for key, value in sorted(environment.items()))
|
|
108
|
+
return (
|
|
109
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
110
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
111
|
+
'<plist version="1.0"><dict>'
|
|
112
|
+
f"<key>Label</key><string>{LAUNCHD_LABEL}</string>"
|
|
113
|
+
f"<key>ProgramArguments</key><array><string>{escape(str(executable))}</string><string>host</string>"
|
|
114
|
+
"<string>reap</string><string>--from-timer</string></array>"
|
|
115
|
+
f"<key>EnvironmentVariables</key><dict>{variables}</dict>"
|
|
116
|
+
f"<key>StartInterval</key><integer>{INTERVAL_SECONDS}</integer>"
|
|
117
|
+
"<key>RunAtLoad</key><true/>"
|
|
118
|
+
f"<key>StandardOutPath</key><string>{escape(str(log))}</string>"
|
|
119
|
+
f"<key>StandardErrorPath</key><string>{escape(str(log))}</string>"
|
|
120
|
+
"</dict></plist>\n"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _quote(value: str) -> str:
|
|
125
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
|
|
126
|
+
return '"' + escaped + '"'
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_systemd(executable: Path, environment: Mapping[str, str]) -> tuple[str, str]:
|
|
130
|
+
variables = "".join(f"Environment={_quote(f'{key}={value}')}\n" for key, value in sorted(environment.items()))
|
|
131
|
+
service = (
|
|
132
|
+
"[Unit]\nDescription=Destroy idle offbox hosts\n\n"
|
|
133
|
+
f"[Service]\nType=oneshot\n{variables}ExecStart={_quote(str(executable))} host reap --from-timer\n"
|
|
134
|
+
)
|
|
135
|
+
timer = "[Unit]\nDescription=Run the offbox idle reaper every 15 minutes\n\n[Timer]\nOnCalendar=*:0/15\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
|
|
136
|
+
return service, timer
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def install(
|
|
140
|
+
paths: Paths, runner: Runner, *, home: Path | None = None, system: str | None = None, environ: Mapping[str, str] | None = None
|
|
141
|
+
) -> list[Path]:
|
|
142
|
+
root = home or Path.home()
|
|
143
|
+
current = system or platform.system()
|
|
144
|
+
executable = executable_path()
|
|
145
|
+
environment = unit_environment(paths, os.environ if environ is None else environ, root)
|
|
146
|
+
preflight(paths, environment, root)
|
|
147
|
+
files = unit_files(paths, root, current)
|
|
148
|
+
if current == "Darwin":
|
|
149
|
+
write_private(files[0], render_launchd(executable, environment, paths.logs / "reaper.log"))
|
|
150
|
+
domain = f"gui/{os.getuid()}"
|
|
151
|
+
runner.run(["launchctl", "bootout", domain, str(files[0])], check=False, timeout=30)
|
|
152
|
+
runner.run(["launchctl", "bootstrap", domain, str(files[0])], timeout=30)
|
|
153
|
+
elif current == "Linux":
|
|
154
|
+
service, timer = render_systemd(executable, environment)
|
|
155
|
+
write_private(files[0], service)
|
|
156
|
+
write_private(files[1], timer)
|
|
157
|
+
runner.run(["systemctl", "--user", "daemon-reload"], timeout=30)
|
|
158
|
+
runner.run(["systemctl", "--user", "enable", "--now", f"{SYSTEMD_UNIT}.timer"], timeout=30)
|
|
159
|
+
else:
|
|
160
|
+
raise PreconditionError(f"The idle reaper timer is not supported on {current}", "Run offbox host reap from your own scheduler.")
|
|
161
|
+
console.success(f"Idle reaper installed: offbox host reap runs every {INTERVAL_SECONDS // 60} minutes")
|
|
162
|
+
return files
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def remove(paths: Paths, runner: Runner, *, home: Path | None = None, system: str | None = None) -> bool:
|
|
166
|
+
root = home or Path.home()
|
|
167
|
+
current = system or platform.system()
|
|
168
|
+
files = unit_files(paths, root, current)
|
|
169
|
+
if not any(path.exists() for path in files):
|
|
170
|
+
return False
|
|
171
|
+
if current == "Darwin":
|
|
172
|
+
runner.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(files[0])], check=False, timeout=30)
|
|
173
|
+
elif current == "Linux":
|
|
174
|
+
runner.run(["systemctl", "--user", "disable", "--now", f"{SYSTEMD_UNIT}.timer"], check=False, timeout=30)
|
|
175
|
+
for path in files:
|
|
176
|
+
path.unlink(missing_ok=True)
|
|
177
|
+
if current == "Linux":
|
|
178
|
+
runner.run(["systemctl", "--user", "daemon-reload"], check=False, timeout=30)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def health(paths: Paths, now: datetime, last_run: datetime | None, failure: str | None, *, home: Path | None = None) -> str | None:
|
|
183
|
+
"""A warning when the installed timer has stopped running or its last run failed."""
|
|
184
|
+
files = unit_files(paths, home or Path.home())
|
|
185
|
+
if not all(path.is_file() for path in files):
|
|
186
|
+
return None
|
|
187
|
+
if failure:
|
|
188
|
+
return f"The last scheduled reap failed: {failure}"
|
|
189
|
+
reference = last_run
|
|
190
|
+
if reference is None:
|
|
191
|
+
try:
|
|
192
|
+
reference = datetime.fromtimestamp(files[0].stat().st_mtime, now.tzinfo)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
if now - reference > STALE_AFTER:
|
|
196
|
+
return "The idle reaper has not run for over an hour (a sleeping laptop also delays it). Fix: offbox doctor, or reinstall with offbox host reap --install-timer."
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def unit_problem(paths: Paths, *, home: Path | None = None, system: str | None = None) -> str | None:
|
|
201
|
+
"""A problem with an installed timer's target executable, such as an uninstalled offbox."""
|
|
202
|
+
files = unit_files(paths, home or Path.home(), system)
|
|
203
|
+
if not all(path.is_file() for path in files):
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
text = files[0].read_text()
|
|
207
|
+
except OSError:
|
|
208
|
+
return "The idle reaper unit cannot be read"
|
|
209
|
+
found = re.search(r"<key>ProgramArguments</key><array><string>([^<]+)</string>", text) or re.search(
|
|
210
|
+
r'^ExecStart="([^"]+)"', text, re.MULTILINE
|
|
211
|
+
)
|
|
212
|
+
if found is None:
|
|
213
|
+
return "The idle reaper unit has no executable"
|
|
214
|
+
executable = Path(unescape(found[1]).replace("%%", "%").replace("$$", "$"))
|
|
215
|
+
if not executable.is_file():
|
|
216
|
+
return f"The idle reaper runs {executable}, which no longer exists"
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_installed_credentials(paths: Paths, *, home: Path | None = None) -> str | None:
|
|
221
|
+
"""Re-run the timer's credential preflight; None when every auto-managed preset can be destroyed by the timer."""
|
|
222
|
+
root = home or Path.home()
|
|
223
|
+
try:
|
|
224
|
+
preflight(paths, unit_environment(paths, os.environ, root), root)
|
|
225
|
+
except PreconditionError as exc:
|
|
226
|
+
return f"{exc.message}. Fix: {exc.fix}"
|
|
227
|
+
return None
|
offbox/host/usage.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable, Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from offbox.config import Model
|
|
12
|
+
from offbox.errors import PreconditionError
|
|
13
|
+
from offbox.host.ready import Clock, SystemClock
|
|
14
|
+
from offbox.locking import acquire_lock
|
|
15
|
+
from offbox.paths import Paths
|
|
16
|
+
from offbox.providers import validate_name
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
USAGE_WAIT_SECONDS = 600
|
|
20
|
+
type WallClock = Callable[[], datetime]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utc_now() -> datetime:
|
|
24
|
+
return datetime.now(UTC)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsageHolder(Model):
|
|
28
|
+
pid: int
|
|
29
|
+
command: str
|
|
30
|
+
started_at: datetime
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _locations(paths: Paths, name: str) -> tuple[Path, Path]:
|
|
34
|
+
validate_name(name)
|
|
35
|
+
directory = paths.state / "host-locks"
|
|
36
|
+
return directory / f"{name}.usage", directory / f"{name}.users"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _open(path: Path) -> int:
|
|
40
|
+
try:
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
42
|
+
return os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise PreconditionError("Cannot open host usage lock", "Check state directory permissions.") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _alive(pid: int) -> bool:
|
|
48
|
+
try:
|
|
49
|
+
os.kill(pid, 0)
|
|
50
|
+
except ProcessLookupError:
|
|
51
|
+
return False
|
|
52
|
+
except PermissionError:
|
|
53
|
+
return True
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def holders(paths: Paths, name: str) -> list[UsageHolder]:
|
|
58
|
+
"""Live commands that hold the shared usage lock, pruning entries of processes that have exited."""
|
|
59
|
+
_, directory = _locations(paths, name)
|
|
60
|
+
found: list[UsageHolder] = []
|
|
61
|
+
try:
|
|
62
|
+
entries = sorted(directory.glob("*.json"))
|
|
63
|
+
except OSError:
|
|
64
|
+
return found
|
|
65
|
+
for entry in entries:
|
|
66
|
+
try:
|
|
67
|
+
holder = UsageHolder.model_validate_json(entry.read_text())
|
|
68
|
+
except (OSError, ValidationError):
|
|
69
|
+
logger.info("ignoring unreadable usage holder %s", entry)
|
|
70
|
+
continue
|
|
71
|
+
if _alive(holder.pid):
|
|
72
|
+
found.append(holder)
|
|
73
|
+
else:
|
|
74
|
+
entry.unlink(missing_ok=True)
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def describe(found: list[UsageHolder]) -> str:
|
|
79
|
+
return ", ".join(f"{holder.command} (pid {holder.pid})" for holder in found) or "an offbox command"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def shared_usage(
|
|
84
|
+
paths: Paths, name: str, command: str, *, wait: float = USAGE_WAIT_SECONDS, clock: Clock | None = None, now: WallClock = utc_now
|
|
85
|
+
) -> Generator[None]:
|
|
86
|
+
"""Mark the host as in use for the duration of the block; destroy paths skip a host while any holder remains."""
|
|
87
|
+
lock, directory = _locations(paths, name)
|
|
88
|
+
descriptor = _open(lock)
|
|
89
|
+
entry = directory / f"{os.getpid()}-{uuid.uuid4().hex}.json"
|
|
90
|
+
try:
|
|
91
|
+
acquire_lock(
|
|
92
|
+
descriptor,
|
|
93
|
+
timeout=wait,
|
|
94
|
+
clock=clock or SystemClock(),
|
|
95
|
+
message=f"Host {name} is being destroyed",
|
|
96
|
+
fix="Retry once the destroy finishes; offbox up recreates an auto-managed host.",
|
|
97
|
+
shared=True,
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
101
|
+
entry.write_text(UsageHolder(pid=os.getpid(), command=command, started_at=now()).model_dump_json())
|
|
102
|
+
except OSError:
|
|
103
|
+
logger.warning("cannot record usage holder for %s", name, exc_info=True)
|
|
104
|
+
yield
|
|
105
|
+
finally:
|
|
106
|
+
entry.unlink(missing_ok=True)
|
|
107
|
+
os.close(descriptor)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def exclusive_usage(paths: Paths, name: str, *, wait: float = 0, clock: Clock | None = None) -> Generator[bool]:
|
|
112
|
+
"""Yield True with the host reserved for destruction, or False when commands still use it after waiting."""
|
|
113
|
+
lock, _ = _locations(paths, name)
|
|
114
|
+
descriptor = _open(lock)
|
|
115
|
+
try:
|
|
116
|
+
try:
|
|
117
|
+
acquire_lock(descriptor, timeout=wait, clock=clock or SystemClock(), message="busy", fix="busy")
|
|
118
|
+
except PreconditionError:
|
|
119
|
+
yield False
|
|
120
|
+
return
|
|
121
|
+
yield True
|
|
122
|
+
finally:
|
|
123
|
+
os.close(descriptor)
|
offbox/host/warnings.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, ValidationError
|
|
6
|
+
|
|
7
|
+
from offbox.config import Model
|
|
8
|
+
from offbox.paths import Paths
|
|
9
|
+
from offbox.ssh import write_private
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HostWarning(Model):
|
|
15
|
+
host: str
|
|
16
|
+
message: str
|
|
17
|
+
created_at: datetime
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HostWarnings(Model):
|
|
21
|
+
warnings: list[HostWarning] = Field(default_factory=list[HostWarning])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _warnings_path(paths: Paths) -> Path:
|
|
25
|
+
return paths.state / "warnings.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_warnings(paths: Paths) -> list[HostWarning]:
|
|
29
|
+
try:
|
|
30
|
+
return HostWarnings.model_validate_json(_warnings_path(paths).read_text()).warnings
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
return []
|
|
33
|
+
except (OSError, ValidationError):
|
|
34
|
+
logger.warning("ignoring unreadable host warnings", exc_info=True)
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_warnings(paths: Paths, warnings: list[HostWarning]) -> None:
|
|
39
|
+
write_private(_warnings_path(paths), HostWarnings(warnings=warnings).model_dump_json(indent=2) + "\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dismiss_warning(paths: Paths, name: str) -> bool:
|
|
43
|
+
current = read_warnings(paths)
|
|
44
|
+
kept = [warning for warning in current if warning.host != name]
|
|
45
|
+
if len(kept) == len(current):
|
|
46
|
+
return False
|
|
47
|
+
_write_warnings(paths, kept)
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def warn_interrupted(paths: Paths, name: str) -> None:
|
|
52
|
+
"""Keep a reminder after destroying a host whose creation was killed mid-apply, which can leave untracked resources."""
|
|
53
|
+
warnings = [warning for warning in read_warnings(paths) if warning.host != name]
|
|
54
|
+
warnings.append(
|
|
55
|
+
HostWarning(
|
|
56
|
+
host=name,
|
|
57
|
+
message=f"Creation of {name} was interrupted; check the provider console for resources labelled offbox-host={name}",
|
|
58
|
+
created_at=datetime.now(UTC),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
_write_warnings(paths, warnings)
|
offbox/locking.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from offbox.errors import PreconditionError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from offbox.host.ready import Clock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def acquire_lock(descriptor: int, *, timeout: float = 0, clock: Clock, message: str, fix: str, shared: bool = False) -> None:
|
|
13
|
+
timer = clock
|
|
14
|
+
deadline: float | None = None
|
|
15
|
+
while True:
|
|
16
|
+
try:
|
|
17
|
+
fcntl.flock(descriptor, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
|
18
|
+
return
|
|
19
|
+
except BlockingIOError as exc:
|
|
20
|
+
if timeout <= 0:
|
|
21
|
+
raise PreconditionError(message, fix) from exc
|
|
22
|
+
if deadline is None:
|
|
23
|
+
deadline = timer.now() + timeout
|
|
24
|
+
remaining = deadline - timer.now()
|
|
25
|
+
if remaining <= 0:
|
|
26
|
+
raise PreconditionError(f"{message} after waiting {timeout:g}s", fix) from exc
|
|
27
|
+
timer.sleep(min(0.05, remaining))
|
offbox/logging.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from datetime import UTC, date, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from offbox.config import Model
|
|
12
|
+
from offbox.paths import Paths
|
|
13
|
+
|
|
14
|
+
_registered_secrets: set[str] = set()
|
|
15
|
+
_SECRET_NAME = re.compile(r"(?:TOKEN|SECRET|PASSWORD|KEY|CREDENTIALS|KEYFILE_JSON)$", re.IGNORECASE)
|
|
16
|
+
_STANDALONE_VALUE_LENGTH = 20
|
|
17
|
+
_NO_LOG_OPTIONS = frozenset({"--help", "--version"})
|
|
18
|
+
_invocation: ContextVar[str] = ContextVar("offbox_invocation", default="")
|
|
19
|
+
_environment: ContextVar[str | None] = ContextVar("offbox_environment", default=None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LogEntry(Model):
|
|
23
|
+
timestamp: datetime
|
|
24
|
+
invocation: str
|
|
25
|
+
env: str | None
|
|
26
|
+
level: str
|
|
27
|
+
logger: str
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def current_invocation() -> str:
|
|
32
|
+
return _invocation.get()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bind_environment(name: str) -> None:
|
|
36
|
+
_environment.set(name)
|
|
37
|
+
logging.getLogger(__name__).debug("worktree context established")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def close_logs() -> None:
|
|
41
|
+
root = logging.getLogger("offbox")
|
|
42
|
+
for handler in root.handlers[:]:
|
|
43
|
+
root.removeHandler(handler)
|
|
44
|
+
handler.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StructuredFormatter(logging.Formatter):
|
|
48
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
49
|
+
message = record.getMessage()
|
|
50
|
+
if record.exc_info:
|
|
51
|
+
message += "\n" + self.formatException(record.exc_info)
|
|
52
|
+
return LogEntry(
|
|
53
|
+
timestamp=datetime.fromtimestamp(record.created, UTC),
|
|
54
|
+
invocation=_invocation.get(),
|
|
55
|
+
env=_environment.get(),
|
|
56
|
+
level=record.levelname,
|
|
57
|
+
logger=record.name,
|
|
58
|
+
message=redact(message),
|
|
59
|
+
).model_dump_json()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_secrets(env: Mapping[str, str]) -> None:
|
|
63
|
+
values = [value for name, value in env.items() if value and _SECRET_NAME.search(name)]
|
|
64
|
+
_registered_secrets.update(values)
|
|
65
|
+
_registered_secrets.update(part for value in values for part in value.splitlines() if part)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_env_values(env: Mapping[str, str]) -> None:
|
|
69
|
+
"""Redact forwarded values as NAME=value; on their own only for secret-looking names or long values, so paths stay readable."""
|
|
70
|
+
for name, value in env.items():
|
|
71
|
+
if not value:
|
|
72
|
+
continue
|
|
73
|
+
named = _SECRET_NAME.search(name) is not None
|
|
74
|
+
standalone = named or len(value) >= _STANDALONE_VALUE_LENGTH
|
|
75
|
+
_registered_secrets.add(f"{name}={value}")
|
|
76
|
+
if standalone:
|
|
77
|
+
_registered_secrets.add(value)
|
|
78
|
+
_registered_secrets.update(part for part in value.splitlines() if part and (named or len(part) >= _STANDALONE_VALUE_LENGTH))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_exec_secrets(argv: Sequence[str]) -> None:
|
|
82
|
+
command = next((index for index, value in enumerate(argv) if not value.startswith("-")), None)
|
|
83
|
+
if command is None or argv[command] != "exec":
|
|
84
|
+
return
|
|
85
|
+
index = command + 1
|
|
86
|
+
while index < len(argv):
|
|
87
|
+
argument = argv[index]
|
|
88
|
+
if argument == "--":
|
|
89
|
+
return
|
|
90
|
+
entry: str | None = None
|
|
91
|
+
if argument == "--env":
|
|
92
|
+
index += 1
|
|
93
|
+
if index >= len(argv) or argv[index] == "--":
|
|
94
|
+
return
|
|
95
|
+
entry = argv[index]
|
|
96
|
+
elif argument.startswith("--env="):
|
|
97
|
+
entry = argument[len("--env=") :]
|
|
98
|
+
if entry is not None:
|
|
99
|
+
name, separator, value = entry.partition("=")
|
|
100
|
+
if separator:
|
|
101
|
+
register_env_values({name: value})
|
|
102
|
+
index += 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def redact(value: str, env: Mapping[str, str] | None = None) -> str:
|
|
106
|
+
values = os.environ if env is None else env
|
|
107
|
+
secrets = sorted(
|
|
108
|
+
_registered_secrets | {item for name, item in values.items() if item and _SECRET_NAME.search(name)}, key=len, reverse=True
|
|
109
|
+
)
|
|
110
|
+
for secret in secrets:
|
|
111
|
+
for variant in sorted({secret, repr(secret)[1:-1], json.dumps(secret)[1:-1]}, key=len, reverse=True):
|
|
112
|
+
value = value.replace(variant, "[REDACTED]")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RedactingFormatter(logging.Formatter):
|
|
117
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
118
|
+
return redact(super().format(record))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def configure(paths: Paths, argv: Sequence[str], day: date | None = None) -> Path:
|
|
122
|
+
current = day or date.today()
|
|
123
|
+
paths.logs.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
124
|
+
unpruned: list[Path] = []
|
|
125
|
+
for candidate in paths.logs.glob("*.log"):
|
|
126
|
+
try:
|
|
127
|
+
file_day = date.fromisoformat(candidate.stem)
|
|
128
|
+
except ValueError:
|
|
129
|
+
continue
|
|
130
|
+
if file_day <= current - timedelta(days=14):
|
|
131
|
+
try:
|
|
132
|
+
candidate.unlink(missing_ok=True)
|
|
133
|
+
except OSError:
|
|
134
|
+
unpruned.append(candidate)
|
|
135
|
+
target = paths.log_file(current)
|
|
136
|
+
handler = logging.FileHandler(target, encoding="utf-8")
|
|
137
|
+
target.chmod(0o600)
|
|
138
|
+
handler.setFormatter(StructuredFormatter())
|
|
139
|
+
root = logging.getLogger("offbox")
|
|
140
|
+
close_logs()
|
|
141
|
+
_invocation.set(uuid.uuid4().hex)
|
|
142
|
+
_environment.set(None)
|
|
143
|
+
root.setLevel(logging.DEBUG)
|
|
144
|
+
root.addHandler(handler)
|
|
145
|
+
root.propagate = False
|
|
146
|
+
for candidate in unpruned:
|
|
147
|
+
root.warning("cannot prune old log %s", candidate)
|
|
148
|
+
if not _NO_LOG_OPTIONS.intersection(_options(argv)):
|
|
149
|
+
root.info("invocation argv=%s", [redact(arg) for arg in argv])
|
|
150
|
+
return target
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _options(argv: Sequence[str]) -> list[str]:
|
|
154
|
+
return list(argv[: argv.index("--")] if "--" in argv else argv)
|