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/console.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _human(message: str, style: str = "") -> None:
|
|
10
|
+
Console(stderr=True, soft_wrap=True, no_color="NO_COLOR" in os.environ or not sys.stderr.isatty()).print(
|
|
11
|
+
message, style=style, markup=False, highlight=False
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def output(value: str) -> None:
|
|
16
|
+
print(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def text(value: str) -> None:
|
|
20
|
+
sys.stdout.write(value)
|
|
21
|
+
sys.stdout.flush()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version(value: str) -> None:
|
|
25
|
+
output(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def info(message: str) -> None:
|
|
29
|
+
_human(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def success(message: str) -> None:
|
|
33
|
+
_human(f"✓ {message}", "green")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warn(message: str) -> None:
|
|
37
|
+
_human(f"! {message}", "yellow")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error(message: str, fix: str | None = None) -> None:
|
|
41
|
+
_human(f"✗ {message}", "red")
|
|
42
|
+
if fix:
|
|
43
|
+
_human(f" Fix: {fix}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def step(message: str) -> None:
|
|
47
|
+
_human(f"→ {message}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def command(message: str) -> None:
|
|
51
|
+
_human(f"$ {message}", "dim")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def interactive() -> bool:
|
|
55
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def confirm(message: str) -> bool:
|
|
59
|
+
return bool(typer.confirm(message, default=True, err=True))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def json_out(model: BaseModel) -> None:
|
|
63
|
+
output(model.model_dump_json())
|
offbox/doctor.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from offbox.config import Credentials, Model, UserConfig, default_host, load_config, load_credentials
|
|
6
|
+
from offbox.errors import OffboxError, PreconditionError
|
|
7
|
+
from offbox.host.auto import timer_status
|
|
8
|
+
from offbox.host.timer import health as timer_health
|
|
9
|
+
from offbox.host.timer import unit_problem
|
|
10
|
+
from offbox.host.usage import utc_now
|
|
11
|
+
from offbox.host.warnings import read_warnings
|
|
12
|
+
from offbox.paths import Paths
|
|
13
|
+
from offbox.project import load_project
|
|
14
|
+
from offbox.runner import Runner
|
|
15
|
+
from offbox.ssh import default_ssh_key
|
|
16
|
+
from offbox.state import State, StateStore
|
|
17
|
+
from offbox.tools import ToolDiscovery
|
|
18
|
+
|
|
19
|
+
NO_DEFAULT_HOST = "No default host configured"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Check(Model):
|
|
23
|
+
name: str
|
|
24
|
+
ok: bool
|
|
25
|
+
message: str
|
|
26
|
+
fix: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DoctorReport(Model):
|
|
30
|
+
checks: list[Check] = Field(default_factory=list[Check])
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
return all(check.ok for check in self.checks)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def exit_code(self) -> int:
|
|
38
|
+
return 0 if self.ok else 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _failure(name: str, error: OffboxError) -> Check:
|
|
42
|
+
return Check(name=name, ok=False, message=error.message, fix=error.fix or "Correct the reported problem and retry offbox doctor.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_key(runner: Runner, key: Path, *, configured: bool = False) -> Check:
|
|
46
|
+
agent = runner.run(["ssh-add", "-l"], check=False, timeout=5)
|
|
47
|
+
if agent.returncode == 0 and agent.stdout.strip():
|
|
48
|
+
if not configured and not key.exists():
|
|
49
|
+
return Check(name="ssh-key", ok=True, message="SSH agent has a usable identity")
|
|
50
|
+
fingerprint_path = key if key.exists() else key.with_name(key.name + ".pub")
|
|
51
|
+
fingerprint = runner.run(["ssh-keygen", "-lf", str(fingerprint_path)], check=False, timeout=5)
|
|
52
|
+
fields = fingerprint.stdout.split()
|
|
53
|
+
if fingerprint.returncode == 0 and len(fields) > 1 and fields[1] in agent.stdout:
|
|
54
|
+
return Check(name="ssh-key", ok=True, message="SSH key is available in the agent")
|
|
55
|
+
if key.is_file():
|
|
56
|
+
unlocked = runner.run(["ssh-keygen", "-y", "-P", "", "-f", str(key)], check=False, timeout=5)
|
|
57
|
+
if unlocked.returncode == 0:
|
|
58
|
+
return Check(name="ssh-key", ok=True, message="SSH key is usable without a prompt")
|
|
59
|
+
return Check(
|
|
60
|
+
name="ssh-key",
|
|
61
|
+
ok=False,
|
|
62
|
+
message=f"SSH key is unavailable without a prompt: {key}",
|
|
63
|
+
fix=f"Generate {key} or load it with ssh-add {key}.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def diagnose(
|
|
68
|
+
paths: Paths, runner: Runner, *, project_root: Path | None = None, tools: ToolDiscovery | None = None, ssh_key: Path | None = None
|
|
69
|
+
) -> DoctorReport:
|
|
70
|
+
report = DoctorReport()
|
|
71
|
+
discovery = tools or ToolDiscovery(runner, paths.cache)
|
|
72
|
+
for name in ("ssh", "git", "mutagen", "tofu"):
|
|
73
|
+
try:
|
|
74
|
+
tool = discovery.check(name)
|
|
75
|
+
source = ", downloaded by offbox" if tool.path.is_relative_to(paths.cache) else ""
|
|
76
|
+
report.checks.append(Check(name=name, ok=True, message=f"{tool.name} {tool.version} ({tool.path}{source})"))
|
|
77
|
+
except OffboxError as exc:
|
|
78
|
+
report.checks.append(_failure(name, exc))
|
|
79
|
+
user: UserConfig | None = None
|
|
80
|
+
state: State | None = None
|
|
81
|
+
try:
|
|
82
|
+
user = load_config(paths.user_config, UserConfig)
|
|
83
|
+
report.checks.append(Check(name="user-config", ok=True, message="User config parses"))
|
|
84
|
+
except OffboxError as exc:
|
|
85
|
+
report.checks.append(_failure("user-config", exc))
|
|
86
|
+
try:
|
|
87
|
+
root = project_root
|
|
88
|
+
if root is None:
|
|
89
|
+
root = Path.cwd()
|
|
90
|
+
git_available = any(check.name == "git" and check.ok for check in report.checks)
|
|
91
|
+
if git_available:
|
|
92
|
+
detected = runner.run(["git", "rev-parse", "--show-toplevel"], check=False, cwd=root, timeout=5)
|
|
93
|
+
if detected.returncode == 0 and detected.stdout.strip():
|
|
94
|
+
root = Path(detected.stdout.strip())
|
|
95
|
+
load_project(paths, root)
|
|
96
|
+
report.checks.append(Check(name="project-config", ok=True, message="Project config parses"))
|
|
97
|
+
except OffboxError as exc:
|
|
98
|
+
report.checks.append(_failure("project-config", exc))
|
|
99
|
+
try:
|
|
100
|
+
credentials: Credentials = load_credentials(paths.credentials)
|
|
101
|
+
del credentials
|
|
102
|
+
report.checks.append(Check(name="credentials", ok=True, message="Credentials configuration is valid"))
|
|
103
|
+
except OffboxError as exc:
|
|
104
|
+
report.checks.append(_failure("credentials", exc))
|
|
105
|
+
try:
|
|
106
|
+
state = StateStore(paths.state).read()
|
|
107
|
+
report.checks.append(Check(name="state", ok=True, message="State validates"))
|
|
108
|
+
except OffboxError as exc:
|
|
109
|
+
report.checks.append(_failure("state", exc))
|
|
110
|
+
key = default_ssh_key()
|
|
111
|
+
configured_key = False
|
|
112
|
+
if user is not None and state is not None:
|
|
113
|
+
selected = default_host(user, state.default_host)
|
|
114
|
+
auto = sorted(name for name, preset in user.hosts.items() if preset.auto)
|
|
115
|
+
if selected is None and auto:
|
|
116
|
+
report.checks.append(
|
|
117
|
+
Check(name="host", ok=True, message=f"Auto-managed hosts are created on first offbox up: {', '.join(auto)}")
|
|
118
|
+
)
|
|
119
|
+
elif selected is None:
|
|
120
|
+
report.checks.append(Check(name="host", ok=False, message=NO_DEFAULT_HOST, fix="Run offbox setup."))
|
|
121
|
+
else:
|
|
122
|
+
host = state.hosts.get(selected)
|
|
123
|
+
if host is None and selected in auto:
|
|
124
|
+
report.checks.append(Check(name="host", ok=True, message=f"Auto-managed host {selected} is created on first offbox up"))
|
|
125
|
+
elif host is None:
|
|
126
|
+
report.checks.append(
|
|
127
|
+
_failure(
|
|
128
|
+
"host",
|
|
129
|
+
PreconditionError(f"Default host {selected} is not registered", "Create the host or choose a registered host."),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
configured_key = host.ssh_key is not None or (selected in user.hosts and user.hosts[selected].ssh_key is not None)
|
|
134
|
+
key = host.ssh_key or (user.hosts[selected].ssh_key if selected in user.hosts else None) or key
|
|
135
|
+
try:
|
|
136
|
+
result = runner.run(
|
|
137
|
+
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", f"offbox-{selected}", "--", "true"], check=False, timeout=5
|
|
138
|
+
)
|
|
139
|
+
report.checks.append(
|
|
140
|
+
Check(
|
|
141
|
+
name="host",
|
|
142
|
+
ok=result.returncode == 0,
|
|
143
|
+
message=f"Default host {selected} " + ("is reachable" if result.returncode == 0 else "is unreachable"),
|
|
144
|
+
fix=None if result.returncode == 0 else f"Check connectivity with offbox host ssh {selected}.",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
except OffboxError as exc:
|
|
148
|
+
report.checks.append(_failure("host", exc))
|
|
149
|
+
if state is not None:
|
|
150
|
+
last_run, failure = timer_status(paths)
|
|
151
|
+
warning = timer_health(paths, utc_now(), last_run, failure)
|
|
152
|
+
if warning is None:
|
|
153
|
+
warning = unit_problem(paths)
|
|
154
|
+
if warning is not None:
|
|
155
|
+
report.checks.append(
|
|
156
|
+
Check(
|
|
157
|
+
name="reaper",
|
|
158
|
+
ok=False,
|
|
159
|
+
message=warning,
|
|
160
|
+
fix=f"Run offbox host reap --dry-run, check {paths.logs / 'reaper.log'} (macOS) or journalctl --user -u offbox-reap (Linux), "
|
|
161
|
+
"then reinstall with offbox host reap --install-timer.",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
for stored in read_warnings(paths):
|
|
165
|
+
report.checks.append(
|
|
166
|
+
Check(name="host-warning", ok=False, message=stored.message, fix=f"offbox host list --dismiss {stored.host} once checked.")
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
if ssh_key is not None:
|
|
170
|
+
key, configured_key = ssh_key, True
|
|
171
|
+
report.checks.append(check_key(runner, key.expanduser(), configured=configured_key))
|
|
172
|
+
except OffboxError as exc:
|
|
173
|
+
report.checks.append(_failure("ssh-key", exc))
|
|
174
|
+
return report
|
|
File without changes
|
offbox/drivers/base.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pydantic import field_validator
|
|
2
|
+
|
|
3
|
+
from offbox.config import Model
|
|
4
|
+
from offbox.ssh import validate_address, validate_user
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HostVariables(Model):
|
|
8
|
+
name: str
|
|
9
|
+
size: str
|
|
10
|
+
region: str
|
|
11
|
+
image: str
|
|
12
|
+
ssh_public_key: str
|
|
13
|
+
user_data: str
|
|
14
|
+
labels: dict[str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderOutputs(Model):
|
|
18
|
+
ip: str
|
|
19
|
+
ssh_user: str
|
|
20
|
+
|
|
21
|
+
@field_validator("ip")
|
|
22
|
+
@classmethod
|
|
23
|
+
def check_address(cls, value: str) -> str:
|
|
24
|
+
return validate_address(value)
|
|
25
|
+
|
|
26
|
+
@field_validator("ssh_user")
|
|
27
|
+
@classmethod
|
|
28
|
+
def check_user(cls, value: str) -> str:
|
|
29
|
+
return validate_user(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TofuOutput(Model):
|
|
33
|
+
value: str
|
|
34
|
+
type: str = "string"
|
|
35
|
+
sensitive: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TofuOutputs(Model):
|
|
39
|
+
ip: TofuOutput
|
|
40
|
+
ssh_user: TofuOutput
|
offbox/drivers/none.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import PurePosixPath
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
5
|
+
|
|
6
|
+
from offbox import console
|
|
7
|
+
from offbox.errors import ProviderError
|
|
8
|
+
from offbox.runner import Result, Runner
|
|
9
|
+
from offbox.ssh import ssh_argv
|
|
10
|
+
from offbox.state import HostState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerResource(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
15
|
+
name: str = Field(alias="Name")
|
|
16
|
+
labels: dict[str, str] | None = Field(default=None, alias="Labels")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NetworkEndpoint(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
21
|
+
name: str = Field(alias="Name")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerNetwork(DockerResource):
|
|
25
|
+
containers: dict[str, NetworkEndpoint] | None = Field(default=None, alias="Containers")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DockerConfig(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
30
|
+
labels: dict[str, str] | None = Field(default=None, alias="Labels")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DockerContainer(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
35
|
+
id: str = Field(alias="Id")
|
|
36
|
+
name: str = Field(alias="Name")
|
|
37
|
+
config: DockerConfig = Field(alias="Config")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _verify_resource(name: str, labels: dict[str, str] | None, *, volume: bool = False) -> None:
|
|
41
|
+
values = labels or {}
|
|
42
|
+
env = values.get("offbox.env", "")
|
|
43
|
+
root = PurePosixPath(values.get("offbox.worktree", ""))
|
|
44
|
+
expected = f"offbox-{env}" + ("-docker" if volume else "")
|
|
45
|
+
if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,36}", env) is None or not root.is_absolute() or name.lstrip("/") != expected:
|
|
46
|
+
raise ProviderError(
|
|
47
|
+
"Conflicting Docker ownership labels", "Inspect the host resources and their labels before retrying destruction."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NoneDriver:
|
|
52
|
+
def __init__(self, runner: Runner, host: HostState) -> None:
|
|
53
|
+
self.runner = runner
|
|
54
|
+
self.host = host
|
|
55
|
+
|
|
56
|
+
def _run(self, argv: list[str], *, check: bool = True) -> Result:
|
|
57
|
+
return self.runner.run(ssh_argv(self.host.name, argv), check=check, timeout=120)
|
|
58
|
+
|
|
59
|
+
def destroy(self) -> None:
|
|
60
|
+
containers = self._run(["docker", "ps", "-aq", "--no-trunc", "--filter", "label=offbox.env"]).stdout.split()
|
|
61
|
+
if any(re.fullmatch(r"[0-9a-f]{12,64}", value) is None for value in containers):
|
|
62
|
+
raise ProviderError("Docker returned invalid container identifiers", "Inspect the host Docker daemon before retrying.")
|
|
63
|
+
try:
|
|
64
|
+
if containers:
|
|
65
|
+
inspected = TypeAdapter(list[DockerContainer]).validate_json(
|
|
66
|
+
self._run(["docker", "container", "inspect", *containers]).stdout
|
|
67
|
+
)
|
|
68
|
+
if {item.id for item in inspected} != set(containers) or len(inspected) != len(containers):
|
|
69
|
+
raise ProviderError("Docker inventory changed", "Retry destruction after other Docker operations finish.")
|
|
70
|
+
for container in inspected:
|
|
71
|
+
_verify_resource(container.name, container.config.labels)
|
|
72
|
+
volumes = self._run(["docker", "volume", "ls", "-q", "--filter", "label=offbox.env"]).stdout.split()
|
|
73
|
+
if any(re.fullmatch(r"offbox-[a-z0-9][a-z0-9-]{0,36}-docker", value) is None for value in volumes):
|
|
74
|
+
raise ProviderError("Docker returned unexpected volume names", "Inspect the host Docker volumes before retrying.")
|
|
75
|
+
if volumes:
|
|
76
|
+
inspected_volumes = TypeAdapter(list[DockerResource]).validate_json(
|
|
77
|
+
self._run(["docker", "volume", "inspect", *volumes]).stdout
|
|
78
|
+
)
|
|
79
|
+
if {item.name for item in inspected_volumes} != set(volumes) or len(inspected_volumes) != len(volumes):
|
|
80
|
+
raise ProviderError("Docker volume inventory changed", "Retry destruction after other Docker operations finish.")
|
|
81
|
+
for volume in inspected_volumes:
|
|
82
|
+
_verify_resource(volume.name, volume.labels, volume=True)
|
|
83
|
+
network_result = self._run(["docker", "network", "inspect", "offbox"], check=False)
|
|
84
|
+
owned_network = False
|
|
85
|
+
if network_result.returncode == 0:
|
|
86
|
+
networks = TypeAdapter(list[DockerNetwork]).validate_json(network_result.stdout)
|
|
87
|
+
if len(networks) != 1 or networks[0].name != "offbox":
|
|
88
|
+
raise ProviderError("Unexpected Docker network inventory", "Inspect the offbox network before retrying destruction.")
|
|
89
|
+
owned_network = (networks[0].labels or {}).get("offbox.managed") == "1"
|
|
90
|
+
if owned_network:
|
|
91
|
+
attached = self._run(["docker", "ps", "-aq", "--no-trunc", "--filter", "network=offbox"]).stdout.split()
|
|
92
|
+
if any(re.fullmatch(r"[0-9a-f]{64}", value) is None for value in attached):
|
|
93
|
+
raise ProviderError("Invalid Docker network members", "Inspect network membership before retrying destruction.")
|
|
94
|
+
if (set(attached) | set(networks[0].containers or {})) - set(containers):
|
|
95
|
+
raise ProviderError(
|
|
96
|
+
"Managed network has unrelated attached containers",
|
|
97
|
+
f"Run offbox host ssh {self.host.name} -- docker ps -a --filter network=offbox; disconnect unrelated containers before retrying.",
|
|
98
|
+
)
|
|
99
|
+
if not owned_network:
|
|
100
|
+
console.info("Preserving the adopted or unlabelled Docker network offbox")
|
|
101
|
+
elif network_result.returncode != 1 or not any(
|
|
102
|
+
text in network_result.stderr.lower() for text in ("no such network", "not found")
|
|
103
|
+
):
|
|
104
|
+
raise ProviderError("Cannot inspect the offbox network", "Check host connectivity and Docker before retrying destruction.")
|
|
105
|
+
except ValidationError as exc:
|
|
106
|
+
raise ProviderError("Invalid Docker ownership inventory", "Inspect the host resources before retrying destruction.") from exc
|
|
107
|
+
if containers:
|
|
108
|
+
self._run(["docker", "rm", "-f", *containers])
|
|
109
|
+
if volumes:
|
|
110
|
+
self._run(["docker", "volume", "rm", *volumes])
|
|
111
|
+
if owned_network:
|
|
112
|
+
self._run(["docker", "network", "rm", "offbox"])
|
|
113
|
+
self._run(["sudo", "-n", "rm", "-rf", "/srv/offbox", "/var/lib/offbox"])
|
|
114
|
+
self._run(["sudo", "-n", "rm", "-f", "/etc/sudoers.d/offbox", "/home/offbox/.ssh/authorized_keys"])
|
offbox/drivers/tofu.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from offbox import console
|
|
7
|
+
from offbox.drivers.base import HostVariables, ProviderOutputs, TofuOutputs
|
|
8
|
+
from offbox.errors import CommandFailed, ProviderError
|
|
9
|
+
from offbox.logging import redact
|
|
10
|
+
from offbox.runner import Result, Runner
|
|
11
|
+
from offbox.ssh import write_private
|
|
12
|
+
from offbox.timings import stage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TofuDriver:
|
|
16
|
+
def __init__(self, runner: Runner, workspace: Path, cache: Path, credentials: Mapping[str, str], executable: Path) -> None:
|
|
17
|
+
self.runner = runner
|
|
18
|
+
self.workspace = workspace
|
|
19
|
+
self.executable = str(executable)
|
|
20
|
+
plugins = cache / "tofu-plugins"
|
|
21
|
+
plugins.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
22
|
+
self.env = {
|
|
23
|
+
**credentials,
|
|
24
|
+
"TF_IN_AUTOMATION": "1",
|
|
25
|
+
"TF_INPUT": "0",
|
|
26
|
+
"TF_PLUGIN_CACHE_DIR": str(plugins),
|
|
27
|
+
"TF_CLI_ARGS": "-no-color",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def _run(self, args: Sequence[str]) -> Result:
|
|
31
|
+
result = self.runner.run([self.executable, *args], cwd=self.workspace, env=self.env, check=False, interruptible=True)
|
|
32
|
+
if result.returncode:
|
|
33
|
+
raise CommandFailed([self.executable, *args], result.returncode, redact(result.stderr, self.env))
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
def create(self, variables: HostVariables) -> ProviderOutputs:
|
|
37
|
+
write_private(self.workspace / "offbox.auto.tfvars.json", variables.model_dump_json(indent=2))
|
|
38
|
+
console.step("Initialising provider")
|
|
39
|
+
with stage("Provider initialization"):
|
|
40
|
+
self._run(["init", "-input=false"])
|
|
41
|
+
console.step("Creating host")
|
|
42
|
+
with stage("VM provisioning"):
|
|
43
|
+
self._run(["apply", "-auto-approve", "-input=false"])
|
|
44
|
+
output = self._run(["output", "-json"])
|
|
45
|
+
try:
|
|
46
|
+
values = TofuOutputs.model_validate_json(output.stdout)
|
|
47
|
+
return ProviderOutputs(ip=values.ip.value, ssh_user=values.ssh_user.value)
|
|
48
|
+
except ValidationError as exc:
|
|
49
|
+
raise ProviderError(
|
|
50
|
+
"Provider returned invalid host outputs", "Check provider outputs; destroy the host before retrying."
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
def destroy(self) -> None:
|
|
54
|
+
console.step("Initialising provider")
|
|
55
|
+
with stage("Provider initialization"):
|
|
56
|
+
self._run(["init", "-input=false"])
|
|
57
|
+
console.step("Destroying host")
|
|
58
|
+
with stage("VM deletion"):
|
|
59
|
+
self._run(["destroy", "-auto-approve", "-input=false"])
|
offbox/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OffboxError(Exception):
|
|
5
|
+
def __init__(self, message: str, fix: str | None = None, exit_code: int = 1) -> None:
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.message = message
|
|
8
|
+
self.fix = fix
|
|
9
|
+
self.exit_code = exit_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UsageError(OffboxError):
|
|
13
|
+
def __init__(self, message: str, fix: str | None = None) -> None:
|
|
14
|
+
super().__init__(message, fix, 2)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PreconditionError(OffboxError):
|
|
18
|
+
def __init__(self, message: str, fix: str | None = None) -> None:
|
|
19
|
+
super().__init__(message, fix, 3)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConnectionError_(OffboxError):
|
|
23
|
+
def __init__(self, message: str, fix: str | None = None) -> None:
|
|
24
|
+
super().__init__(message, fix, 4)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandFailed(OffboxError):
|
|
28
|
+
def __init__(self, argv: Sequence[str], returncode: int, stderr_tail: str) -> None:
|
|
29
|
+
self.argv = list(argv)
|
|
30
|
+
self.returncode = returncode
|
|
31
|
+
self.stderr_tail = "\n".join(stderr_tail.splitlines()[-20:])
|
|
32
|
+
message = f"Command failed (exit {returncode}): {' '.join(argv)}"
|
|
33
|
+
if self.stderr_tail:
|
|
34
|
+
message += f"\n{self.stderr_tail}"
|
|
35
|
+
super().__init__(message, "Check the command output and try again.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProviderError(OffboxError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProviderInterrupted(OffboxError):
|
|
43
|
+
def __init__(self, message: str, fix: str, *, killed: bool) -> None:
|
|
44
|
+
super().__init__(message, fix, 130)
|
|
45
|
+
self.killed = killed
|