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
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)
@@ -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)