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/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
|