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