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/logs.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable, Generator
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from offbox.errors import CommandFailed, ConnectionError_, PreconditionError, UsageError
|
|
9
|
+
from offbox.host.ready import Clock, SystemClock
|
|
10
|
+
from offbox.logging import LogEntry, current_invocation
|
|
11
|
+
from offbox.naming import Worktree
|
|
12
|
+
from offbox.paths import Paths
|
|
13
|
+
from offbox.runner import Runner
|
|
14
|
+
from offbox.runtime import Runtime
|
|
15
|
+
from offbox.ssh import ssh_argv
|
|
16
|
+
from offbox.state import HostState
|
|
17
|
+
|
|
18
|
+
_PENDING_INVOCATIONS = 256
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LogTail:
|
|
22
|
+
"""Incrementally select an environment's log lines, including its invocations' lines written before the worktree was known."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, env: str, *, exclude: str = "") -> None:
|
|
25
|
+
self.env = env
|
|
26
|
+
self.exclude = exclude
|
|
27
|
+
self.offset = 0
|
|
28
|
+
self.partial = b""
|
|
29
|
+
self.owned: set[str] = set()
|
|
30
|
+
self.pending: dict[str, list[str]] = {}
|
|
31
|
+
|
|
32
|
+
def read(self, path: Path) -> list[str]:
|
|
33
|
+
try:
|
|
34
|
+
with path.open("rb") as source:
|
|
35
|
+
if os.fstat(source.fileno()).st_size < self.offset:
|
|
36
|
+
self.offset, self.partial = 0, b""
|
|
37
|
+
source.seek(self.offset)
|
|
38
|
+
data = source.read()
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return []
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise PreconditionError(f"Cannot read log file {path}", "Check log permissions and encoding.") from exc
|
|
43
|
+
self.offset += len(data)
|
|
44
|
+
*complete, self.partial = (self.partial + data).split(b"\n")
|
|
45
|
+
selected: list[str] = []
|
|
46
|
+
for raw in complete:
|
|
47
|
+
try:
|
|
48
|
+
line = raw.decode("utf-8") + "\n"
|
|
49
|
+
except UnicodeError as exc:
|
|
50
|
+
raise PreconditionError(f"Cannot read log file {path}", "Check log permissions and encoding.") from exc
|
|
51
|
+
selected.extend(self._select(line))
|
|
52
|
+
return selected
|
|
53
|
+
|
|
54
|
+
def _select(self, line: str) -> list[str]:
|
|
55
|
+
try:
|
|
56
|
+
entry = LogEntry.model_validate_json(line)
|
|
57
|
+
except ValidationError:
|
|
58
|
+
return []
|
|
59
|
+
invocation = entry.invocation
|
|
60
|
+
if self.exclude and invocation == self.exclude:
|
|
61
|
+
return []
|
|
62
|
+
if entry.env == self.env:
|
|
63
|
+
earlier: list[str] = []
|
|
64
|
+
if invocation and invocation not in self.owned:
|
|
65
|
+
self.owned.add(invocation)
|
|
66
|
+
earlier = self.pending.pop(invocation, [])
|
|
67
|
+
return [*earlier, line]
|
|
68
|
+
if entry.env is None and invocation:
|
|
69
|
+
if invocation in self.owned:
|
|
70
|
+
return [line]
|
|
71
|
+
self.pending.setdefault(invocation, []).append(line)
|
|
72
|
+
while len(self.pending) > _PENDING_INVOCATIONS:
|
|
73
|
+
del self.pending[next(iter(self.pending))]
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def environment_records(path: Path, env: str) -> list[str]:
|
|
78
|
+
return LogTail(env).read(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def follow_lines(
|
|
82
|
+
paths: Paths, env: str, *, follow: bool = False, clock: Clock | None = None, today: Callable[[], date] = date.today
|
|
83
|
+
) -> Generator[str]:
|
|
84
|
+
timer = clock or SystemClock()
|
|
85
|
+
selected: date | None = None
|
|
86
|
+
tail = LogTail(env, exclude=current_invocation())
|
|
87
|
+
while True:
|
|
88
|
+
current = today()
|
|
89
|
+
if current != selected:
|
|
90
|
+
selected, tail = current, LogTail(env, exclude=current_invocation())
|
|
91
|
+
yield from tail.read(paths.log_file(current))
|
|
92
|
+
if not follow:
|
|
93
|
+
return
|
|
94
|
+
timer.sleep(0.2)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stack_logs(paths: Paths, runner: Runner, *, follow: bool = False, service: str | None = None, cwd: Path | None = None) -> int:
|
|
98
|
+
if service is not None and (not service or service.startswith("-") or any(ord(character) < 32 for character in service)):
|
|
99
|
+
raise UsageError("Invalid Compose service", "Pass a Compose service name after --stack.")
|
|
100
|
+
runtime = Runtime(paths, runner)
|
|
101
|
+
with runtime.using("offbox logs --stack", cwd) as (work, _env, host):
|
|
102
|
+
return _stack_logs(runtime, runner, work, host, follow=follow, service=service)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stack_logs(runtime: Runtime, runner: Runner, work: Worktree, host: HostState, *, follow: bool, service: str | None) -> int:
|
|
106
|
+
container = runtime.inspect(host, work)
|
|
107
|
+
if container is None or not container.state.running:
|
|
108
|
+
raise PreconditionError("Environment is not running", "Run offbox up before reading stack logs.")
|
|
109
|
+
labels = container.config.labels or {}
|
|
110
|
+
if (
|
|
111
|
+
container.name.lstrip("/") != work.container
|
|
112
|
+
or labels.get("offbox.env") != work.name
|
|
113
|
+
or labels.get("offbox.worktree") != str(work.root)
|
|
114
|
+
):
|
|
115
|
+
raise PreconditionError("Environment ownership does not match", "Inspect offbox list and the container labels.")
|
|
116
|
+
argv = [
|
|
117
|
+
"docker",
|
|
118
|
+
"exec",
|
|
119
|
+
"-u",
|
|
120
|
+
"dev",
|
|
121
|
+
"-w",
|
|
122
|
+
"/work",
|
|
123
|
+
work.container,
|
|
124
|
+
"docker",
|
|
125
|
+
"compose",
|
|
126
|
+
"logs",
|
|
127
|
+
*(["--follow"] if follow else []),
|
|
128
|
+
*([service] if service else []),
|
|
129
|
+
]
|
|
130
|
+
status = runner.stream(ssh_argv(host.name, argv, options=["-n"]))
|
|
131
|
+
if status == 255:
|
|
132
|
+
raise ConnectionError_("SSH connection lost while streaming Compose logs", "Run offbox status and check host connectivity.")
|
|
133
|
+
if status:
|
|
134
|
+
raise CommandFailed(argv, status, "Compose log streaming failed; check the service name and offbox status.")
|
|
135
|
+
return 0
|
offbox/naming.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import field_validator
|
|
7
|
+
|
|
8
|
+
from offbox.config import Model
|
|
9
|
+
from offbox.errors import PreconditionError
|
|
10
|
+
from offbox.logging import bind_environment
|
|
11
|
+
from offbox.runner import Runner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_env_name(name: str) -> str:
|
|
15
|
+
if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,36}", name) is None:
|
|
16
|
+
raise PreconditionError("Invalid environment name", "Repair the environment state before continuing.")
|
|
17
|
+
return name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def worktree_name(root: Path) -> str:
|
|
21
|
+
absolute = Path(os.path.abspath(root))
|
|
22
|
+
slug = re.sub(r"[^a-z0-9]+", "-", absolute.name.lower()).strip("-") or "env"
|
|
23
|
+
return slug[:32] + "-" + hashlib.sha256(str(absolute).encode()).hexdigest()[:4]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Worktree(Model):
|
|
27
|
+
root: Path
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
@field_validator("name")
|
|
31
|
+
@classmethod
|
|
32
|
+
def check_name(cls, value: str) -> str:
|
|
33
|
+
return validate_env_name(value)
|
|
34
|
+
|
|
35
|
+
@field_validator("root")
|
|
36
|
+
@classmethod
|
|
37
|
+
def check_root(cls, value: Path) -> Path:
|
|
38
|
+
if not value.is_absolute():
|
|
39
|
+
raise ValueError("Worktree root must be absolute")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def container(self) -> str:
|
|
44
|
+
return f"offbox-{self.name}"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def volume(self) -> str:
|
|
48
|
+
return f"offbox-{self.name}-docker"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def directory(self) -> str:
|
|
52
|
+
return f"/srv/offbox/envs/{self.name}"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def work(self) -> str:
|
|
56
|
+
return f"{self.directory}/work"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_worktree(runner: Runner, cwd: Path | None = None) -> Worktree:
|
|
60
|
+
result = runner.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd or Path.cwd(), check=False, timeout=5)
|
|
61
|
+
root = result.stdout.rstrip("\n")
|
|
62
|
+
if result.returncode or not root or not Path(root).is_absolute():
|
|
63
|
+
raise PreconditionError("Not inside a git worktree", "cd into a git worktree")
|
|
64
|
+
absolute = Path(os.path.abspath(root))
|
|
65
|
+
work = Worktree(root=absolute, name=worktree_name(absolute))
|
|
66
|
+
bind_environment(work.name)
|
|
67
|
+
return work
|
offbox/paths.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from offbox.errors import PreconditionError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Paths:
|
|
13
|
+
config: Path
|
|
14
|
+
state: Path
|
|
15
|
+
cache: Path
|
|
16
|
+
control: Path
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def discover(cls, env: Mapping[str, str] | None = None, home: Path | None = None) -> "Paths":
|
|
20
|
+
values = os.environ if env is None else env
|
|
21
|
+
base = home if home is not None else Path.home()
|
|
22
|
+
return cls(
|
|
23
|
+
Path(values.get("XDG_CONFIG_HOME") or base / ".config") / "offbox",
|
|
24
|
+
Path(values.get("XDG_STATE_HOME") or base / ".local/state") / "offbox",
|
|
25
|
+
Path(values.get("XDG_CACHE_HOME") or base / ".cache") / "offbox",
|
|
26
|
+
Path(f"/tmp/offbox-{os.getuid()}"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def user_config(self) -> Path:
|
|
31
|
+
return self.config / "config.toml"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def credentials(self) -> Path:
|
|
35
|
+
return self.config / "credentials.toml"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def logs(self) -> Path:
|
|
39
|
+
return self.state / "logs"
|
|
40
|
+
|
|
41
|
+
def log_file(self, day: date | None = None) -> Path:
|
|
42
|
+
return self.logs / f"{day or date.today()}.log"
|
|
43
|
+
|
|
44
|
+
def secure_roots(self) -> None:
|
|
45
|
+
for root in (self.state, self.cache):
|
|
46
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
47
|
+
if stat.S_IMODE(root.stat().st_mode) != 0o700:
|
|
48
|
+
root.chmod(0o700)
|
|
49
|
+
|
|
50
|
+
def ensure_control(self) -> Path:
|
|
51
|
+
self.control.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
52
|
+
if self.control.is_symlink() or self.control.stat().st_uid != os.getuid():
|
|
53
|
+
raise PreconditionError(f"Unsafe SSH control directory: {self.control}", "Remove the directory and retry.")
|
|
54
|
+
self.control.chmod(0o700)
|
|
55
|
+
return self.control
|