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