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/cli/host.py ADDED
@@ -0,0 +1,313 @@
1
+ import sys
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal
6
+
7
+ import typer
8
+ from pydantic import Field, TypeAdapter
9
+ from typer._click import Context
10
+ from typer.core import TyperCommand
11
+
12
+ from offbox import console
13
+ from offbox.config import Model
14
+ from offbox.errors import OffboxError, PreconditionError, UsageError
15
+ from offbox.host import select_host
16
+ from offbox.host.auto import host_activity, timer_status
17
+ from offbox.host.auto import reap as reap_hosts
18
+ from offbox.host.destroy import destroy_host
19
+ from offbox.host.timer import health as timer_health
20
+ from offbox.host.timer import install as install_reaper
21
+ from offbox.host.timer import installed as timer_installed
22
+ from offbox.host.timer import remove as remove_reaper
23
+ from offbox.host.usage import shared_usage, utc_now
24
+ from offbox.host.warnings import HostWarning, dismiss_warning, read_warnings
25
+ from offbox.paths import Paths
26
+ from offbox.providers import validate_name
27
+ from offbox.runner import Runner, SubprocessRunner
28
+ from offbox.ssh import ssh_argv
29
+ from offbox.state import HostState, StateStore
30
+
31
+ app = typer.Typer(no_args_is_help=True, help="Create, register and manage hosts.")
32
+
33
+
34
+ class HostSummary(Model):
35
+ name: str
36
+ provider: str
37
+ ip: str
38
+ envs: int
39
+ reachable: bool
40
+ phase: Literal["creating", "initializing", "ready"]
41
+ default: bool
42
+ auto: bool = False
43
+ idle_seconds: int | None = None
44
+ last_activity: datetime | None = None
45
+ reaps_after: datetime | None = None
46
+
47
+
48
+ class HostReport(Model):
49
+ hosts: list[HostSummary] = Field(default_factory=list[HostSummary])
50
+ timer_installed: bool = False
51
+ timer_warning: str | None = None
52
+ warnings: list[HostWarning] = Field(default_factory=list[HostWarning])
53
+
54
+
55
+ def host_report(paths: Paths, runner: Runner) -> HostReport:
56
+ state = StateStore(paths.state).read()
57
+
58
+ def describe(host: HostState) -> HostSummary:
59
+ reachable = False
60
+ if host.address:
61
+ try:
62
+ reachable = (
63
+ runner.run(ssh_argv(host.name, ["true"], options=["-o", "ConnectTimeout=3"]), check=False, timeout=3).returncode == 0
64
+ )
65
+ except OffboxError:
66
+ reachable = False
67
+ activity = host_activity(paths, state, host) if host.auto else None
68
+ return HostSummary(
69
+ name=host.name,
70
+ provider=host.provider,
71
+ ip=host.address,
72
+ envs=sum(env.host == host.name for env in state.envs.values()),
73
+ reachable=reachable,
74
+ phase=host.phase,
75
+ default=state.default_host == host.name,
76
+ auto=host.auto,
77
+ idle_seconds=host.idle_seconds,
78
+ last_activity=activity,
79
+ reaps_after=activity + timedelta(seconds=host.idle_seconds) if activity is not None and host.idle_seconds else None,
80
+ )
81
+
82
+ with ThreadPoolExecutor(max_workers=min(8, max(1, len(state.hosts)))) as executor:
83
+ hosts = list(executor.map(describe, [host for _, host in sorted(state.hosts.items())]))
84
+ last_run, failure = timer_status(paths)
85
+ return HostReport(
86
+ hosts=hosts,
87
+ timer_installed=timer_installed(paths),
88
+ timer_warning=timer_health(paths, utc_now(), last_run, failure),
89
+ warnings=read_warnings(paths),
90
+ )
91
+
92
+
93
+ def _when(moment: datetime) -> str:
94
+ return moment.astimezone().strftime("%Y-%m-%d %H:%M")
95
+
96
+
97
+ def use_host(paths: Paths, name: str) -> None:
98
+ validate_name(name)
99
+ with StateStore(paths.state).transaction() as state:
100
+ if name not in state.hosts or state.hosts[name].phase != "ready":
101
+ raise PreconditionError(f"Host {name} is not ready or registered", "Create or initialise the host before selecting it.")
102
+ if state.hosts[name].auto:
103
+ raise PreconditionError(
104
+ f"Host {name} is auto-managed and cannot be the default", "Select it per project with env.host instead."
105
+ )
106
+ state.default_host = name
107
+ console.success(f"Default host: {name}")
108
+
109
+
110
+ def _runner(ctx: typer.Context) -> SubprocessRunner:
111
+ value: object = ctx.find_root().obj
112
+ if not isinstance(value, SubprocessRunner):
113
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
114
+ return value
115
+
116
+
117
+ @app.command()
118
+ def create(
119
+ ctx: typer.Context,
120
+ name: Annotated[str, typer.Argument(help="Host name used in offbox commands and the offbox-NAME SSH alias.")] = "main",
121
+ provider: Annotated[str | None, typer.Option("--provider", help="Cloud provider: hetzner, gcp or a custom provider name.")] = None,
122
+ size: Annotated[str | None, typer.Option("--size", help="Provider machine type, for example ccx33 or e2-standard-4.")] = None,
123
+ region: Annotated[str | None, typer.Option("--region", help="Provider location or zone, for example fsn1 or us-east4-c.")] = None,
124
+ ssh_key: Annotated[
125
+ Path | None, typer.Option("--ssh-key", help="Private SSH key for the host. Default: ~/.ssh/id_ed25519, else ~/.ssh/offbox_ed25519.")
126
+ ] = None,
127
+ ) -> None:
128
+ """Provision a VM and wait for its bootstrap to complete."""
129
+ from offbox.host.create import CreateOptions, create_host
130
+
131
+ create_host(Paths.discover(), _runner(ctx), CreateOptions(name=name, provider=provider, size=size, region=region, ssh_key=ssh_key))
132
+
133
+
134
+ @app.command()
135
+ def init(
136
+ ctx: typer.Context,
137
+ target: Annotated[str, typer.Argument(help="Existing server login as USER@ADDRESS; USER needs passwordless sudo.")],
138
+ name: Annotated[str, typer.Option("--name", help="Host name used in offbox commands.")] = "main",
139
+ port: Annotated[int, typer.Option("--port", min=1, max=65535, help="SSH port of the server.")] = 22,
140
+ host_key_fingerprint: Annotated[
141
+ str | None,
142
+ typer.Option(
143
+ "--host-key-fingerprint",
144
+ help="Expected SHA256:... ed25519 host key fingerprint (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub).",
145
+ ),
146
+ ] = None,
147
+ accept_host_key: Annotated[
148
+ bool, typer.Option("--accept-host-key", help="Trust the scanned host key without verification; prefer --host-key-fingerprint.")
149
+ ] = False,
150
+ ssh_key: Annotated[
151
+ Path | None,
152
+ typer.Option("--ssh-key", help="Private SSH key for the server. Default: ~/.ssh/id_ed25519, else ~/.ssh/offbox_ed25519."),
153
+ ] = None,
154
+ ) -> None:
155
+ """Bootstrap an existing server while preserving its owner's access."""
156
+ from offbox.host.init import InitOptions, init_host
157
+
158
+ init_host(
159
+ Paths.discover(),
160
+ _runner(ctx),
161
+ InitOptions(
162
+ target=target,
163
+ name=name,
164
+ port=port,
165
+ accept_host_key=accept_host_key,
166
+ host_key_fingerprint=host_key_fingerprint,
167
+ ssh_key=ssh_key,
168
+ ),
169
+ confirm=typer.confirm if sys.stdin.isatty() else None,
170
+ )
171
+
172
+
173
+ @app.command("list")
174
+ def list_hosts(
175
+ ctx: typer.Context,
176
+ json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False,
177
+ dismiss: Annotated[str | None, typer.Option("--dismiss", help="Clear the stored warning for this host name.")] = None,
178
+ ) -> None:
179
+ """List registered hosts, their reachability and auto-managed lifecycle."""
180
+ paths = Paths.discover()
181
+ if dismiss is not None:
182
+ validate_name(dismiss)
183
+ if dismiss_warning(paths, dismiss):
184
+ console.success(f"Dismissed the warning for {dismiss}")
185
+ else:
186
+ console.info(f"No warning stored for {dismiss}")
187
+ return
188
+ report = host_report(paths, _runner(ctx))
189
+ if json:
190
+ console.json_out(report)
191
+ return
192
+ if not report.hosts:
193
+ console.info("No hosts registered")
194
+ for host in report.hosts:
195
+ line = f"{host.name} {host.provider} {host.ip or 'pending'} envs={host.envs} reachable={'yes' if host.reachable else 'no'} {host.phase}"
196
+ if host.auto:
197
+ idle = f"idle limit {host.idle_seconds // 60}m" if host.idle_seconds else "idle reaping off"
198
+ line += f" auto ({idle}"
199
+ if host.last_activity is not None:
200
+ line += f", last used {_when(host.last_activity)}"
201
+ if host.reaps_after is not None:
202
+ line += f", reaped after {_when(host.reaps_after)}"
203
+ line += ")"
204
+ console.info(line)
205
+ for warning in report.warnings:
206
+ console.warn(f"{warning.message}. Fix: offbox host list --dismiss {warning.host} once checked.")
207
+ if report.timer_warning:
208
+ console.warn(report.timer_warning)
209
+ elif any(host.auto and host.idle_seconds for host in report.hosts) and not report.timer_installed:
210
+ console.info("Idle reaping needs the timer: offbox host reap --install-timer")
211
+
212
+
213
+ @app.command()
214
+ def use(name: Annotated[str, typer.Argument(help="Registered host to use by default.")]) -> None:
215
+ """Choose the default host."""
216
+ use_host(Paths.discover(), name)
217
+
218
+
219
+ @app.command()
220
+ def destroy(
221
+ ctx: typer.Context,
222
+ name: Annotated[str | None, typer.Argument(help="Host to destroy. Default: the default host.")] = None,
223
+ yes: Annotated[bool, typer.Option("--yes", help="Confirm without a prompt.")] = False,
224
+ forget: Annotated[
225
+ bool, typer.Option("--forget", help="For an unreachable SSH host: remove only the local record and leave the server untouched.")
226
+ ] = False,
227
+ ) -> None:
228
+ """Destroy a host and its environments."""
229
+ paths = Paths.discover()
230
+ host = select_host(paths, name)
231
+ envs = [env.name for env in StateStore(paths.state).read().envs.values() if env.host == host.name]
232
+ action = "Forgetting" if forget else "Destroying"
233
+ console.warn(f"{action} {host.name} loses environments: {', '.join(envs) if envs else '(none)'}")
234
+ if not yes:
235
+ if not sys.stdin.isatty():
236
+ flags = " --forget" if forget else ""
237
+ raise PreconditionError("Destruction requires --yes without a TTY", f"Run offbox host destroy {host.name}{flags} --yes.")
238
+ if not typer.confirm(f"{'Forget' if forget else 'Destroy'} host {host.name}?"):
239
+ raise PreconditionError("Destruction cancelled", "Run the command again when ready.")
240
+ destroy_host(paths, _runner(ctx), host.name, forget=forget)
241
+
242
+
243
+ class HostSshCommand(TyperCommand):
244
+ def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
245
+ if "--" in args:
246
+ index = args.index("--")
247
+ ctx.meta["offbox_remote_command"] = tuple(args[index + 1 :])
248
+ args = args[:index]
249
+ return super().parse_args(ctx, args)
250
+
251
+ def collect_usage_pieces(self, ctx: Context) -> list[str]:
252
+ return [*super().collect_usage_pieces(ctx), "[-- COMMAND [ARGS]...]"]
253
+
254
+
255
+ @app.command(cls=HostSshCommand)
256
+ def ssh(ctx: typer.Context, name: Annotated[str | None, typer.Argument(help="Host to open. Default: the default host.")] = None) -> None:
257
+ """Open a host shell or run a command after --."""
258
+ host = select_host(Paths.discover(), name)
259
+ if not host.address:
260
+ raise PreconditionError("Host has no recorded address", f"Run offbox host destroy {host.name} to clean up the pending host.")
261
+ raw: object = ctx.meta.get("offbox_remote_command", ())
262
+ command = TypeAdapter(list[str]).validate_python(raw)
263
+ tty = not command
264
+ if tty and not sys.stdin.isatty():
265
+ raise PreconditionError("Interactive SSH requires a TTY", "Pass a command after -- for noninteractive SSH.")
266
+ paths = Paths.discover()
267
+ with shared_usage(paths, host.name, "offbox host ssh"):
268
+ if select_host(paths, host.name) != host:
269
+ raise PreconditionError(f"Host {host.name} changed while starting", "Retry the command.")
270
+ code = _runner(ctx).stream(ssh_argv(host.name, command, options=["-t"] if tty else []), stdin=tty, tty=tty)
271
+ raise typer.Exit(code)
272
+
273
+
274
+ @app.command()
275
+ def reap(
276
+ ctx: typer.Context,
277
+ name: Annotated[str | None, typer.Argument(help="Auto-managed host to check. Default: every auto-managed host.")] = None,
278
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be destroyed without changing anything.")] = False,
279
+ json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False,
280
+ install_timer: Annotated[
281
+ bool, typer.Option("--install-timer", help="Run the reaper every 15 minutes from launchd or systemd.")
282
+ ] = False,
283
+ remove_timer: Annotated[bool, typer.Option("--remove-timer", help="Remove the scheduled reaper.")] = False,
284
+ from_timer: Annotated[bool, typer.Option("--from-timer", hidden=True, help="Record this run as the scheduled reaper's.")] = False,
285
+ ) -> None:
286
+ """Destroy idle auto-managed hosts and hosts whose creation never finished."""
287
+ paths, runner = Paths.discover(), _runner(ctx)
288
+ if install_timer and remove_timer:
289
+ raise UsageError("Choose --install-timer or --remove-timer", "Run one of them.")
290
+ if install_timer:
291
+ install_reaper(paths, runner)
292
+ return
293
+ if remove_timer:
294
+ if remove_reaper(paths, runner):
295
+ console.success("Idle reaper removed")
296
+ else:
297
+ console.info("No idle reaper installed")
298
+ return
299
+ report = reap_hosts(paths, runner, name, dry_run=dry_run, source="timer" if from_timer else "manual")
300
+ if json:
301
+ console.json_out(report)
302
+ else:
303
+ if not report.hosts:
304
+ console.info("No auto-managed hosts")
305
+ for outcome in report.hosts:
306
+ action = "would destroy" if outcome.action == "destroy" else outcome.action
307
+ lost = f"; environments lost: {', '.join(outcome.lost_environments)}" if outcome.lost_environments else ""
308
+ message = f"{outcome.name}: {action} ({outcome.reason}{lost})"
309
+ if outcome.action == "failed":
310
+ console.error(message, outcome.error)
311
+ else:
312
+ console.info(message)
313
+ raise typer.Exit(report.exit_code)
offbox/cli/misc.py ADDED
@@ -0,0 +1,132 @@
1
+ from pathlib import Path
2
+ from typing import Annotated, Literal
3
+
4
+ import typer
5
+
6
+ from offbox import console
7
+ from offbox.errors import OffboxError, UsageError
8
+ from offbox.logs import follow_lines, stack_logs
9
+ from offbox.naming import find_worktree
10
+ from offbox.paths import Paths
11
+ from offbox.runner import SubprocessRunner
12
+ from offbox.status import Status
13
+ from offbox.uninstall import uninstall as run_uninstall
14
+
15
+
16
+ def _runner(ctx: typer.Context) -> SubprocessRunner:
17
+ runner: object = ctx.find_root().obj
18
+ if not isinstance(runner, SubprocessRunner):
19
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
20
+ return runner
21
+
22
+
23
+ def status(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
24
+ """Check this worktree's host, environment, sync, ports and stack."""
25
+ report = Status(Paths.discover(), _runner(ctx)).diagnose()
26
+ if json:
27
+ console.json_out(report)
28
+ else:
29
+ for check in report.checks:
30
+ if check.level == "ok":
31
+ console.success(check.message)
32
+ elif check.level == "warning":
33
+ console.warn(check.message)
34
+ console.info(f" Fix: {check.fix}")
35
+ else:
36
+ console.error(check.message, check.fix)
37
+ if check.name == "ports":
38
+ for forward in report.ports.forwards:
39
+ state = "active" if forward.active else "inactive"
40
+ console.info(f" localhost:{forward.local} → {forward.env}:{forward.remote} ({state})")
41
+ raise typer.Exit(report.exit_code)
42
+
43
+
44
+ def logs(
45
+ ctx: typer.Context,
46
+ service: Annotated[str | None, typer.Argument(help="Compose service to show; requires --stack.")] = None,
47
+ follow: Annotated[bool, typer.Option("--follow", help="Keep streaming new lines.")] = False,
48
+ stack: Annotated[bool, typer.Option("--stack", help="Show the project's Compose stack logs instead of offbox's log.")] = False,
49
+ ) -> None:
50
+ """Read today's environment log or stream Compose logs with --stack [SERVICE]."""
51
+ if service is not None and not stack:
52
+ raise UsageError("A service requires --stack", "Run offbox logs --stack SERVICE.")
53
+ runner, paths = _runner(ctx), Paths.discover()
54
+ try:
55
+ if stack:
56
+ raise typer.Exit(stack_logs(paths, runner, follow=follow, service=service))
57
+ work = find_worktree(runner)
58
+ for line in follow_lines(paths, work.name, follow=follow):
59
+ console.text(line)
60
+ except KeyboardInterrupt as exc:
61
+ raise typer.Exit(130) from exc
62
+
63
+
64
+ def setup(
65
+ ctx: typer.Context,
66
+ provider: Annotated[
67
+ Literal["hetzner", "gcp", "ssh"] | None, typer.Option("--provider", help="Host provider: hetzner, gcp or ssh (existing server).")
68
+ ] = None,
69
+ action: Annotated[
70
+ Literal["check", "create", "init"] | None,
71
+ typer.Option("--action", help="check prerequisites only, create a cloud host, or init an existing server."),
72
+ ] = None,
73
+ name: Annotated[str, typer.Option("--name", help="Host name to create or register.")] = "main",
74
+ ssh_key: Annotated[
75
+ Path | None, typer.Option("--ssh-key", help="Private SSH key. Default: ~/.ssh/id_ed25519, else ~/.ssh/offbox_ed25519.")
76
+ ] = None,
77
+ size: Annotated[str | None, typer.Option("--size", help="Cloud machine type for --action create.")] = None,
78
+ region: Annotated[str | None, typer.Option("--region", help="Cloud location or zone for --action create.")] = None,
79
+ target: Annotated[str | None, typer.Option("--target", help="USER@ADDRESS of the existing server for --action init.")] = None,
80
+ port: Annotated[int, typer.Option("--port", min=1, max=65535, help="SSH port of the existing server.")] = 22,
81
+ host_key_fingerprint: Annotated[
82
+ str | None, typer.Option("--host-key-fingerprint", help="Expected SHA256:... host key fingerprint for --action init.")
83
+ ] = None,
84
+ accept_host_key: Annotated[
85
+ bool, typer.Option("--accept-host-key", help="Trust the scanned host key without verification; prefer --host-key-fingerprint.")
86
+ ] = False,
87
+ ) -> None:
88
+ """Check prerequisites, install missing tools and explicitly create or register a host."""
89
+ from offbox.setup import SetupOptions
90
+ from offbox.setup import setup as run_setup
91
+
92
+ def ask(message: str, default: str) -> str:
93
+ return str(typer.prompt(message, default=default))
94
+
95
+ interactive = console.interactive()
96
+ run_setup(
97
+ Paths.discover(),
98
+ _runner(ctx),
99
+ SetupOptions(
100
+ provider=provider,
101
+ action=action,
102
+ name=name,
103
+ ssh_key=ssh_key,
104
+ size=size,
105
+ region=region,
106
+ target=target,
107
+ port=port,
108
+ accept_host_key=accept_host_key,
109
+ host_key_fingerprint=host_key_fingerprint,
110
+ ),
111
+ ask=ask if interactive else None,
112
+ confirm=typer.confirm if interactive else None,
113
+ offer=console.confirm if interactive else None,
114
+ )
115
+
116
+
117
+ def uninstall(
118
+ ctx: typer.Context,
119
+ yes: Annotated[bool, typer.Option("--yes", help="Confirm without a prompt.")] = False,
120
+ abandon_cloud_hosts: Annotated[
121
+ bool, typer.Option("--abandon-cloud-hosts", help="Remove local records even though cloud hosts keep running and billing.")
122
+ ] = False,
123
+ ) -> None:
124
+ """Remove local offbox resources while retaining user configuration and remote hosts."""
125
+ run_uninstall(Paths.discover(), _runner(ctx), yes=yes, abandon_cloud_hosts=abandon_cloud_hosts)
126
+
127
+
128
+ def register(app: typer.Typer) -> None:
129
+ app.command()(status)
130
+ app.command()(logs)
131
+ app.command()(setup)
132
+ app.command()(uninstall)
offbox/cli/ports.py ADDED
@@ -0,0 +1,44 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from offbox import console
6
+ from offbox.errors import OffboxError
7
+ from offbox.host import host_operation
8
+ from offbox.paths import Paths
9
+ from offbox.project import load_project
10
+ from offbox.runner import SubprocessRunner
11
+ from offbox.runtime import Runtime
12
+
13
+
14
+ def ports(
15
+ ctx: typer.Context,
16
+ json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False,
17
+ swap: Annotated[
18
+ int | None,
19
+ typer.Option(
20
+ "--swap", min=1, max=65535, help="Give this worktree localhost:PORT for remote PORT, swapping with its current owner."
21
+ ),
22
+ ] = None,
23
+ ) -> None:
24
+ """Restore this worktree's port forwards and show its localhost mappings."""
25
+ runner: object = ctx.find_root().obj
26
+ if not isinstance(runner, SubprocessRunner):
27
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
28
+ paths = Paths.discover()
29
+ runtime = Runtime(paths, runner)
30
+ with runtime.using("offbox ports") as (work, _env, host):
31
+ config = load_project(paths, work.root)
32
+ with host_operation(paths, host.name, wait_timeout=120, clock=runtime.clock):
33
+ report = runtime.ports.restore(work, host, config.ports)
34
+ if swap is not None:
35
+ report = runtime.ports.swap(work, swap)
36
+ if json:
37
+ console.json_out(report)
38
+ else:
39
+ for forward in report.forwards:
40
+ console.output(f"localhost:{forward.local} → {forward.env}:{forward.remote}")
41
+
42
+
43
+ def register(app: typer.Typer) -> None:
44
+ app.command()(ports)
offbox/config.py ADDED
@@ -0,0 +1,189 @@
1
+ import os
2
+ import re
3
+ import stat
4
+ import tomllib
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Self
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretStr, ValidationError, model_validator
10
+
11
+ from offbox import DEFAULT_IMAGE
12
+ from offbox.errors import PreconditionError
13
+
14
+
15
+ class Model(BaseModel):
16
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
17
+
18
+
19
+ HOST_NAME = r"^[a-z][a-z0-9-]{0,31}$"
20
+ _DURATION = re.compile(r"([1-9][0-9]{0,5})([mhd])")
21
+ _DURATION_SECONDS = {"m": 60, "h": 3600, "d": 86400}
22
+
23
+
24
+ def duration_seconds(value: str) -> int | None:
25
+ """Seconds for a duration such as 30m, 2h or 1d; None for off."""
26
+ if value == "off":
27
+ return None
28
+ match = _DURATION.fullmatch(value)
29
+ if match is None:
30
+ raise ValueError("use a duration such as 30m, 2h or 1d, or off")
31
+ return int(match[1]) * _DURATION_SECONDS[match[2]]
32
+
33
+
34
+ class HostPreset(Model):
35
+ provider: str = "hetzner"
36
+ size: str | None = None
37
+ region: str | None = None
38
+ image: str | None = None
39
+ address: str | None = None
40
+ port: int = Field(default=22, ge=1, le=65535)
41
+ ssh_key: Path | None = None
42
+ auto: bool = False
43
+ idle: str = "2h"
44
+
45
+ @model_validator(mode="after")
46
+ def validate_lifecycle(self) -> Self:
47
+ duration_seconds(self.idle)
48
+ if self.auto and self.provider == "ssh":
49
+ raise ValueError("auto applies to cloud providers; existing SSH servers are never created or destroyed automatically")
50
+ return self
51
+
52
+
53
+ class UserConfig(Model):
54
+ default_host: str | None = None
55
+ hosts: dict[str, HostPreset] = Field(default_factory=dict)
56
+ project: dict[str, dict[str, JsonValue]] = Field(default_factory=dict[str, dict[str, JsonValue]])
57
+
58
+
59
+ class EnvConfig(Model):
60
+ host: str | None = Field(default=None, pattern=HOST_NAME)
61
+ image: str | None = None
62
+ dockerfile: Path | None = None
63
+ cpus: float | None = Field(default=None, gt=0, allow_inf_nan=False)
64
+ memory: str | None = None
65
+ shm_size: str = "2g"
66
+
67
+ @model_validator(mode="after")
68
+ def validate_image(self) -> Self:
69
+ if self.image is not None and self.dockerfile is not None:
70
+ raise ValueError("env.image and env.dockerfile are mutually exclusive")
71
+ if self.image is None and self.dockerfile is None:
72
+ self.image = DEFAULT_IMAGE
73
+ return self
74
+
75
+ def resource_limits(self, host_cpus: int, host_memory: int) -> tuple[float, str]:
76
+ if host_cpus < 1 or host_memory < 1:
77
+ raise PreconditionError("Invalid host resource information", "Check the host CPU and memory information.")
78
+ return self.cpus if self.cpus is not None else float(max(1, host_cpus - 1)), self.memory or str(host_memory * 3 // 4)
79
+
80
+
81
+ class InstallConfig(Model):
82
+ command: str | None = None
83
+ when: list[str] = Field(default_factory=list)
84
+
85
+
86
+ class StackConfig(Model):
87
+ up: str | None = None
88
+ down: str | None = None
89
+
90
+ def commands(self, root: Path) -> tuple[str | None, str | None]:
91
+ compose = any(
92
+ (root / filename).is_file() for filename in ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
93
+ )
94
+ return self.up or ("docker compose up -d" if compose else None), self.down or ("docker compose down" if compose else None)
95
+
96
+
97
+ class PortsConfig(Model):
98
+ forward: list[int] = Field(default_factory=list[int])
99
+ auto: bool = True
100
+
101
+ @model_validator(mode="after")
102
+ def validate_ports(self) -> Self:
103
+ if any(port < 1 or port > 65535 for port in self.forward):
104
+ raise ValueError("ports.forward entries must be between 1 and 65535")
105
+ return self
106
+
107
+
108
+ class SyncConfig(Model):
109
+ ignore: list[str] = Field(default_factory=list)
110
+
111
+
112
+ class ExecConfig(Model):
113
+ env: list[str] = Field(default_factory=list)
114
+
115
+
116
+ class FilesConfig(Model):
117
+ copy_: list[str] = Field(default_factory=list, alias="copy")
118
+
119
+
120
+ class GitConfig(Model):
121
+ mirror: bool = True
122
+ refs: list[str] = Field(default_factory=list)
123
+
124
+
125
+ class ProjectConfig(Model):
126
+ env: EnvConfig = Field(default_factory=EnvConfig)
127
+ install: InstallConfig = Field(default_factory=InstallConfig)
128
+ stack: StackConfig = Field(default_factory=StackConfig)
129
+ ports: PortsConfig = Field(default_factory=PortsConfig)
130
+ sync: SyncConfig = Field(default_factory=SyncConfig)
131
+ exec: ExecConfig = Field(default_factory=ExecConfig)
132
+ files: FilesConfig = Field(default_factory=FilesConfig)
133
+ git: GitConfig = Field(default_factory=GitConfig)
134
+
135
+
136
+ class HetznerCredentials(Model):
137
+ token: SecretStr | None = None
138
+
139
+
140
+ class Credentials(Model):
141
+ hetzner: HetznerCredentials = Field(default_factory=HetznerCredentials)
142
+
143
+
144
+ def load_config[T: BaseModel](path: Path, model: type[T]) -> T:
145
+ try:
146
+ with path.open("rb") as source:
147
+ return model.model_validate(tomllib.load(source))
148
+ except FileNotFoundError:
149
+ return model()
150
+ except ValidationError as exc:
151
+ locations = [".".join(str(part) for part in error["loc"]) or "<root>" for error in exc.errors(include_input=False)]
152
+ raise PreconditionError(f"Invalid configuration in {path}: {', '.join(locations)}", f"Correct these keys in {path}.") from exc
153
+ except (tomllib.TOMLDecodeError, UnicodeError, OSError) as exc:
154
+ raise PreconditionError(f"Cannot read configuration {path}", f"Fix the TOML syntax and permissions in {path}.") from exc
155
+
156
+
157
+ def load_credentials(path: Path) -> Credentials:
158
+ try:
159
+ with path.open("rb") as source:
160
+ if stat.S_IMODE(os.fstat(source.fileno()).st_mode) & 0o177:
161
+ raise PreconditionError(f"Credentials file {path} is more permissive than 0600", f"chmod 600 {path}")
162
+ return Credentials.model_validate(tomllib.load(source))
163
+ except FileNotFoundError:
164
+ return Credentials()
165
+ except ValidationError as exc:
166
+ locations = [".".join(str(part) for part in error["loc"]) or "<root>" for error in exc.errors(include_input=False)]
167
+ raise PreconditionError(
168
+ f"Invalid credentials file {path}: {', '.join(locations)}", f"Correct {path}; keep permissions at 0600."
169
+ ) from exc
170
+ except (tomllib.TOMLDecodeError, UnicodeError, OSError) as exc:
171
+ raise PreconditionError(f"Invalid credentials file {path}", f"Correct {path}; keep permissions at 0600.") from exc
172
+
173
+
174
+ def hetzner_token(path: Path, env: Mapping[str, str] | None = None) -> SecretStr:
175
+ values = os.environ if env is None else env
176
+ token = values.get("HCLOUD_TOKEN")
177
+ if token:
178
+ return SecretStr(token)
179
+ saved = load_credentials(path).hetzner.token
180
+ if saved is not None and saved.get_secret_value():
181
+ return saved
182
+ raise PreconditionError(
183
+ "Missing Hetzner credentials",
184
+ "Set HCLOUD_TOKEN or hetzner.token in credentials.toml (mode 0600); see https://github.com/o-avdieiev/offbox/blob/master/docs/installation.md#hetzner.",
185
+ )
186
+
187
+
188
+ def default_host(user: UserConfig, state_default: str | None) -> str | None:
189
+ return state_default if state_default is not None else user.default_host