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/setup.py ADDED
@@ -0,0 +1,181 @@
1
+ from collections.abc import Callable
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from offbox import console
6
+ from offbox.config import Model
7
+ from offbox.doctor import NO_DEFAULT_HOST, Check, diagnose
8
+ from offbox.errors import OffboxError, PreconditionError
9
+ from offbox.host.create import CreateOptions, create_host
10
+ from offbox.host.init import InitOptions, init_host
11
+ from offbox.paths import Paths
12
+ from offbox.providers import credentials_env, discover_provider
13
+ from offbox.runner import Runner
14
+ from offbox.ssh import default_ssh_key, ensure_include, sync_config
15
+ from offbox.state import StateStore
16
+ from offbox.tools import ToolDiscovery, download_prompt, install_mutagen
17
+
18
+ INSTALLATION_DOCS = "https://github.com/o-avdieiev/offbox/blob/master/docs/installation.md"
19
+ KEYGEN_COMMAND = 'ssh-keygen -t ed25519 -N "" -C offbox -f ~/.ssh/offbox_ed25519'
20
+
21
+
22
+ class SetupOptions(Model):
23
+ provider: Literal["hetzner", "gcp", "ssh"] | None = None
24
+ action: Literal["check", "create", "init"] | None = None
25
+ name: str = "main"
26
+ ssh_key: Path | None = None
27
+ size: str | None = None
28
+ region: str | None = None
29
+ target: str | None = None
30
+ port: int = 22
31
+ accept_host_key: bool = False
32
+ host_key_fingerprint: str | None = None
33
+
34
+
35
+ def credential_fix(provider: str, paths: Paths) -> str | None:
36
+ if provider == "hetzner":
37
+ return (
38
+ f"export HCLOUD_TOKEN=... in your shell profile, or set token under [hetzner] in {paths.credentials} (chmod 600); "
39
+ f"see {INSTALLATION_DOCS}#hetzner"
40
+ )
41
+ if provider == "gcp":
42
+ return f"export GOOGLE_PROJECT=your-project-id and run gcloud auth application-default login; see {INSTALLATION_DOCS}#google-cloud"
43
+ return None
44
+
45
+
46
+ def select_key(runner: Runner, requested: Path | None, offer: Callable[[str], bool] | None, *, create: bool) -> Path | None:
47
+ """Return the SSH key setup should use, or None to keep the ~/.ssh/id_ed25519 default."""
48
+ if requested is not None:
49
+ return requested
50
+ key = default_ssh_key()
51
+ if key.name == "offbox_ed25519":
52
+ console.info(f"Using SSH key {key}")
53
+ return key
54
+ if key.exists() or key.with_name(key.name + ".pub").exists():
55
+ return None
56
+ directory = key.parent
57
+ dedicated = directory / "offbox_ed25519"
58
+ if not create:
59
+ return None
60
+ if offer is None:
61
+ raise PreconditionError("No SSH key found", f"Run {KEYGEN_COMMAND}, then rerun offbox setup.")
62
+ if not offer("No SSH key found. Create a dedicated key ~/.ssh/offbox_ed25519 for offbox hosts?"):
63
+ raise PreconditionError("No SSH key found", f"Run {KEYGEN_COMMAND}, or pass --ssh-key PATH.")
64
+ try:
65
+ directory.mkdir(mode=0o700, exist_ok=True)
66
+ except OSError as exc:
67
+ raise PreconditionError(f"Cannot create {directory}", f"Create {directory} with mode 0700 and retry.") from exc
68
+ runner.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-C", "offbox", "-f", str(dedicated)], timeout=30)
69
+ console.success(f"Created SSH key {dedicated}")
70
+ return dedicated
71
+
72
+
73
+ def _offer_mutagen(paths: Paths, tools: ToolDiscovery, checks: list[Check], offer: Callable[[str], bool] | None) -> list[Check]:
74
+ if offer is None or not any(check.name == "mutagen" and check.message == "Missing tool: mutagen" for check in checks):
75
+ return checks
76
+ if not offer(download_prompt(paths.cache)):
77
+ return checks
78
+ install_mutagen(paths.cache)
79
+ try:
80
+ tool = tools.check("mutagen")
81
+ replacement = Check(name="mutagen", ok=True, message=f"{tool.name} {tool.version} ({tool.path})")
82
+ except OffboxError as exc:
83
+ replacement = Check(name="mutagen", ok=False, message=exc.message, fix=exc.fix)
84
+ return [replacement if check.name == "mutagen" else check for check in checks]
85
+
86
+
87
+ def setup(
88
+ paths: Paths,
89
+ runner: Runner,
90
+ options: SetupOptions,
91
+ *,
92
+ ask: Callable[[str, str], str] | None = None,
93
+ confirm: Callable[[str], bool] | None = None,
94
+ offer: Callable[[str], bool] | None = None,
95
+ ssh_config: Path | None = None,
96
+ tools: ToolDiscovery | None = None,
97
+ ) -> None:
98
+ provider = options.provider
99
+ if provider is None:
100
+ if ask is None:
101
+ raise PreconditionError(
102
+ "Setup needs a provider", "Pass --provider hetzner, --provider gcp or --provider ssh with an explicit --action."
103
+ )
104
+ choice = ask("Provider (hetzner, gcp or ssh)", "hetzner")
105
+ if choice not in ("hetzner", "gcp", "ssh"):
106
+ raise PreconditionError("Unknown setup provider", "Pass --provider hetzner, --provider gcp or --provider ssh.")
107
+ provider = "hetzner" if choice == "hetzner" else "gcp" if choice == "gcp" else "ssh"
108
+ action = options.action
109
+ if action is None:
110
+ if confirm is None:
111
+ raise PreconditionError("Setup needs an explicit action", "Pass --action check, --action create or --action init.")
112
+ action = (
113
+ ("create" if provider != "ssh" else "init")
114
+ if confirm("Create a billable cloud host?" if provider != "ssh" else "Bootstrap an existing SSH host?")
115
+ else "check"
116
+ )
117
+ discovery = tools or ToolDiscovery(runner, paths.cache)
118
+ ssh_key = select_key(runner, options.ssh_key, offer, create=action != "check")
119
+ report = diagnose(paths, runner, ssh_key=ssh_key, tools=discovery)
120
+ report.checks = _offer_mutagen(paths, discovery, report.checks, offer)
121
+ fatal: list[Check] = []
122
+ for check in report.checks:
123
+ required = not (check.name == "host" and (action != "check" or check.message == NO_DEFAULT_HOST)) and not (
124
+ check.name == "tofu" and not (provider != "ssh" and action == "create")
125
+ )
126
+ if check.ok:
127
+ console.success(check.message)
128
+ elif required:
129
+ fatal.append(check)
130
+ console.error(check.message, check.fix)
131
+ else:
132
+ console.warn(f"{check.message}. Fix: {check.fix}")
133
+ if fatal:
134
+ raise PreconditionError("Setup prerequisites are not satisfied", fatal[0].fix or "Run offbox doctor and follow its Fix lines.")
135
+ manifest = discover_provider(provider, paths)
136
+ try:
137
+ credentials_env(manifest.manifest, paths)
138
+ except PreconditionError as exc:
139
+ fix = credential_fix(provider, paths)
140
+ if fix is None or not exc.message.startswith("Missing"):
141
+ raise
142
+ raise PreconditionError(exc.message, fix) from exc
143
+ if action == "create":
144
+ if provider == "ssh":
145
+ raise PreconditionError(
146
+ "The SSH provider cannot create a cloud host", "Use --provider ssh --action init --target USER@ADDRESS."
147
+ )
148
+ ensure_include(paths, ssh_config)
149
+ sync_config(paths, StateStore(paths.state).read())
150
+ create_host(
151
+ paths,
152
+ runner,
153
+ CreateOptions(name=options.name, provider=provider, ssh_key=ssh_key, size=options.size, region=options.region),
154
+ ssh_config=ssh_config,
155
+ )
156
+ elif action == "init":
157
+ if provider != "ssh":
158
+ raise PreconditionError("Existing host initialization requires the SSH provider", "Use --provider ssh --action init.")
159
+ target = options.target or (ask("Existing host USER@ADDRESS (--target)", "") if ask else None)
160
+ if not target:
161
+ raise PreconditionError(
162
+ "Setup needs an SSH target",
163
+ "Pass --provider ssh --action init --target USER@ADDRESS --host-key-fingerprint SHA256:...",
164
+ )
165
+ ensure_include(paths, ssh_config)
166
+ sync_config(paths, StateStore(paths.state).read())
167
+ init_host(
168
+ paths,
169
+ runner,
170
+ InitOptions(
171
+ target=target,
172
+ name=options.name,
173
+ port=options.port,
174
+ ssh_key=ssh_key,
175
+ accept_host_key=options.accept_host_key,
176
+ host_key_fingerprint=options.host_key_fingerprint,
177
+ ),
178
+ confirm=confirm,
179
+ ssh_config=ssh_config,
180
+ )
181
+ console.info("Next: cd <your repo> && offbox up")
offbox/share.py ADDED
@@ -0,0 +1,286 @@
1
+ import base64
2
+ import hashlib
3
+ import logging
4
+ from collections.abc import Mapping, Sequence
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ from offbox import console
8
+ from offbox.config import GitConfig, Model, ProjectConfig
9
+ from offbox.errors import CommandFailed, ConnectionError_, PreconditionError
10
+ from offbox.naming import Worktree
11
+ from offbox.runner import Runner
12
+ from offbox.ssh import ssh_argv
13
+ from offbox.state import EnvState, HostState, StateStore
14
+ from offbox.timings import stage
15
+
16
+ logger = logging.getLogger(__name__)
17
+ MAX_COPY_BYTES = 1024 * 1024
18
+ _BLOCKED_COPIES = (PurePosixPath(".ssh"), PurePosixPath(".config/offbox"), PurePosixPath(".gnupg"))
19
+ _CONTAINER_HOME = PurePosixPath("/home/dev")
20
+ _BLOCKED_FIX = "Remove SSH, GnuPG and offbox configuration files from files.copy; offbox config shows which source sets it."
21
+ _MIRROR_REF = "refs/offbox/head"
22
+ DEFAULT_BRANCH = "origin/HEAD"
23
+ DEFAULT_GIT_SHARING = ("HEAD", DEFAULT_BRANCH)
24
+
25
+ _WRITE_FILE = r"""set -eu
26
+ umask 077
27
+ target=$1
28
+ mkdir -p -- "$(dirname -- "$target")"
29
+ tmp=$(mktemp "$target.XXXXXXXXXX")
30
+ trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
31
+ base64 -d > "$tmp"
32
+ mv -f -- "$tmp" "$target"
33
+ """
34
+ _INIT_REPOSITORY = r"""set -eu
35
+ command -v git >/dev/null 2>&1 || { echo missing-git; exit 0; }
36
+ cd /work
37
+ [ -d .git ] || git init -q
38
+ git config receive.shallowUpdate true
39
+ git config receive.denyCurrentBranch ignore
40
+ """
41
+ _CHECKOUT = r"""set -eu
42
+ cd /work
43
+ head=$1
44
+ branch=$2
45
+ if [ -n "$branch" ]; then
46
+ git update-ref "refs/heads/$branch" "$head"
47
+ git symbolic-ref HEAD "refs/heads/$branch"
48
+ else
49
+ git update-ref --no-deref HEAD "$head"
50
+ fi
51
+ git update-ref -d refs/offbox/head
52
+ mkdir -p .git/info
53
+ cat > .git/info/exclude
54
+ git reset -q --mixed
55
+ """
56
+
57
+
58
+ class MirrorPlan(Model):
59
+ head: str
60
+ branch: str | None
61
+ refs: dict[str, str]
62
+ exclude: str
63
+
64
+ def fingerprint(self) -> str:
65
+ return hashlib.sha256(self.model_dump_json().encode()).hexdigest()
66
+
67
+
68
+ def git_sharing(config: GitConfig) -> list[str]:
69
+ """What the git mirror sends to the environment, as the list offbox up approves; origin/HEAD is the remote's default branch."""
70
+ return [*DEFAULT_GIT_SHARING, *sorted(set(config.refs) - set(DEFAULT_GIT_SHARING))] if config.mirror else []
71
+
72
+
73
+ def copy_path(entry: str) -> PurePosixPath:
74
+ """The home-relative path of a files.copy entry; only regular paths under ~/ outside key and offbox directories."""
75
+ relative = PurePosixPath(entry[2:]) if entry.startswith("~/") else None
76
+ if (
77
+ relative is None
78
+ or not relative.parts
79
+ or any(part in ("", ".", "..") for part in entry[2:].split("/"))
80
+ or any(ord(char) < 32 or ord(char) == 127 for char in entry)
81
+ ):
82
+ raise PreconditionError(f"Invalid files.copy entry: {entry}", "Use paths below your home directory written as ~/path/to/file.")
83
+ if _blocked(relative):
84
+ raise PreconditionError(f"files.copy must not copy {entry}", _BLOCKED_FIX)
85
+ return relative
86
+
87
+
88
+ def _blocked(relative: PurePosixPath) -> bool:
89
+ """Case-insensitive, because macOS home directories usually are."""
90
+ folded = PurePosixPath(str(relative).casefold())
91
+ return any(folded.is_relative_to(blocked) for blocked in _BLOCKED_COPIES)
92
+
93
+
94
+ def unapproved(configured: Sequence[str], approved: Sequence[str] | None) -> list[str]:
95
+ return sorted(set(configured) - set(approved or ()))
96
+
97
+
98
+ def check_sharing(project: ProjectConfig, recorded: EnvState) -> None:
99
+ """Refuse files.copy entries and git refs that offbox up has not approved; the VM can edit offbox.toml through sync."""
100
+ for entry in project.files.copy_:
101
+ copy_path(entry)
102
+ files = unapproved(project.files.copy_, recorded.approved_files)
103
+ refs = unapproved(git_sharing(project.git), DEFAULT_GIT_SHARING if recorded.approved_git is None else recorded.approved_git)
104
+ if files or refs:
105
+ names = [*(f"files.copy {entry}" for entry in files), *(f"git {ref}" for ref in refs)]
106
+ raise PreconditionError(
107
+ "offbox up has not approved sharing: " + ", ".join(names),
108
+ "Review files.copy and git with offbox config, then run offbox up to approve them.",
109
+ )
110
+
111
+
112
+ def sharing_additions(project: ProjectConfig, recorded: EnvState) -> list[str]:
113
+ """Newly configured sharing that this up approves; the first up of an environment approves silently."""
114
+ messages: list[str] = []
115
+ for entry in project.files.copy_:
116
+ copy_path(entry)
117
+ if recorded.approved_files is not None and (files := unapproved(project.files.copy_, recorded.approved_files)):
118
+ messages.append("Copying new local files to the environment: " + ", ".join(files))
119
+ if recorded.approved_git is not None and (refs := unapproved(git_sharing(project.git), recorded.approved_git)):
120
+ messages.append("Mirroring new git refs to the environment: " + ", ".join(refs))
121
+ return messages
122
+
123
+
124
+ class Sharing:
125
+ def __init__(self, runner: Runner, home: Path | None = None) -> None:
126
+ self.runner = runner
127
+ self.home = home or Path.home()
128
+
129
+ def _container(self, host: HostState, work: Worktree, script: str, args: Sequence[str], *, input: str, timeout: float) -> str:
130
+ argv = ["docker", "exec", "-i", "-u", "dev", work.container, "sh", "-c", script, "offbox-share", *args]
131
+ result = self.runner.run(ssh_argv(host.name, argv), check=False, input=input, timeout=timeout)
132
+ if result.returncode == 255:
133
+ raise ConnectionError_(f"Cannot reach host {host.name}", f"Run offbox host ssh {host.name} and check connectivity.")
134
+ if result.returncode:
135
+ raise CommandFailed(["docker", "exec", work.container, "offbox-share"], result.returncode, result.stderr)
136
+ return result.stdout
137
+
138
+ def copy_files(
139
+ self, work: Worktree, host: HostState, entries: Sequence[str], copied: Mapping[str, str], *, force: bool
140
+ ) -> dict[str, str]:
141
+ """Copy changed files.copy entries into the container home and return the digests now present there."""
142
+ result: dict[str, str] = {}
143
+ for entry in entries:
144
+ relative = copy_path(entry)
145
+ source = self.home.joinpath(*relative.parts)
146
+ fix = f"Create {source} or remove {entry} from files.copy; offbox config shows which source sets it."
147
+ try:
148
+ if not source.is_file():
149
+ raise PreconditionError(f"files.copy source is not a regular file: {source}", fix)
150
+ home = self.home.resolve()
151
+ resolved = source.resolve()
152
+ if not resolved.is_relative_to(home) or _blocked(PurePosixPath(resolved.relative_to(home).as_posix())):
153
+ raise PreconditionError(f"files.copy must not copy {entry}, which resolves to {resolved}", _BLOCKED_FIX)
154
+ if source.stat().st_size > MAX_COPY_BYTES:
155
+ raise PreconditionError(f"files.copy source is larger than 1 MiB: {source}", "Copy large files another way.")
156
+ content = source.read_bytes()
157
+ except OSError as exc:
158
+ raise PreconditionError(f"Cannot read files.copy source {source}", fix) from exc
159
+ digest = hashlib.sha256(content).hexdigest()
160
+ if force or copied.get(entry) != digest:
161
+ target = _CONTAINER_HOME.joinpath(*relative.parts)
162
+ self._container(host, work, _WRITE_FILE, [str(target)], input=base64.b64encode(content).decode(), timeout=60)
163
+ logger.info("copied %s into %s", entry, work.name)
164
+ result[entry] = digest
165
+ return result
166
+
167
+ def _git(self, root: Path, *args: str) -> str:
168
+ return self.runner.run(["git", "-C", str(root), *args], timeout=30).stdout.strip()
169
+
170
+ def plan(self, root: Path, refs: Sequence[str]) -> MirrorPlan | None:
171
+ """What to mirror, or None while the repository has no commit yet."""
172
+ current = self.runner.run(["git", "-C", str(root), "rev-parse", "--verify", "-q", "HEAD^{commit}"], check=False, timeout=30)
173
+ if current.returncode:
174
+ return None
175
+ head = current.stdout.strip()
176
+ symbolic = self.runner.run(["git", "-C", str(root), "symbolic-ref", "-q", "--short", "HEAD"], check=False, timeout=30)
177
+ resolved: dict[str, str] = {}
178
+ for ref in sorted(set(refs)):
179
+ named = self.runner.run(["git", "-C", str(root), "rev-parse", "--symbolic-full-name", ref], check=False, timeout=30)
180
+ full = named.stdout.strip()
181
+ if named.returncode or not full.startswith("refs/"):
182
+ raise PreconditionError(
183
+ f"git.refs entry is not a local branch, tag or remote-tracking ref: {ref}",
184
+ "Fetch it or remove it from git.refs; offbox config shows which source sets it.",
185
+ )
186
+ resolved[full] = self._git(root, "rev-parse", "--verify", f"{full}^{{commit}}")
187
+ exclude = root / self._git(root, "rev-parse", "--git-path", "info/exclude")
188
+ try:
189
+ text = exclude.read_text() if exclude.is_file() else ""
190
+ except OSError as exc:
191
+ raise PreconditionError(f"Cannot read {exclude}", "Check the repository's info/exclude permissions.") from exc
192
+ default = self.runner.run(["git", "-C", str(root), "symbolic-ref", "-q", "refs/remotes/origin/HEAD"], check=False, timeout=30)
193
+ target = default.stdout.strip()
194
+ if default.returncode == 0 and target.startswith("refs/remotes/origin/") and target not in resolved:
195
+ commit = self.runner.run(
196
+ ["git", "-C", str(root), "rev-parse", "--verify", "-q", f"{target}^{{commit}}"], check=False, timeout=30
197
+ )
198
+ if commit.returncode == 0:
199
+ resolved[target] = commit.stdout.strip()
200
+ else:
201
+ logger.info("origin/HEAD points to missing %s; mirroring without the default branch", target)
202
+ branch = symbolic.stdout.strip() if symbolic.returncode == 0 and symbolic.stdout.strip() else None
203
+ return MirrorPlan(head=head, branch=branch, refs=resolved, exclude=text)
204
+
205
+ def mirror(self, work: Worktree, host: HostState, plan: MirrorPlan) -> bool:
206
+ """Push HEAD and the configured refs into /work/.git and point its index at HEAD without touching synced files.
207
+
208
+ Returns False without pushing when the environment image has no git."""
209
+ if self._container(host, work, _INIT_REPOSITORY, [], input="", timeout=60).strip() == "missing-git":
210
+ return False
211
+ receive = f"docker exec -i -u dev {work.container} git-receive-pack"
212
+ specs = [f"{plan.head}:{_MIRROR_REF}", *(f"{sha}:{ref}" for ref, sha in plan.refs.items())]
213
+ result = self.runner.run(
214
+ [
215
+ "git",
216
+ "-C",
217
+ str(work.root),
218
+ "push",
219
+ "--force",
220
+ "--quiet",
221
+ "--no-verify",
222
+ "--receive-pack",
223
+ receive,
224
+ f"offbox-{host.name}:/work",
225
+ *specs,
226
+ ],
227
+ check=False,
228
+ env={"GIT_SSH_COMMAND": "ssh"},
229
+ timeout=600,
230
+ )
231
+ if result.returncode:
232
+ raise PreconditionError(
233
+ "Cannot mirror git history to the environment: " + (result.stderr.strip().splitlines() or ["git push failed"])[-1],
234
+ "Check offbox status, then retry; set git.mirror = false (or OFFBOX_GIT_MIRROR=false) to run without git.",
235
+ )
236
+ try:
237
+ self._container(host, work, _CHECKOUT, [plan.head, plan.branch or ""], input=plan.exclude, timeout=120)
238
+ except CommandFailed as exc:
239
+ raise PreconditionError(
240
+ "Cannot check out the mirrored commit in the environment: " + (exc.stderr_tail.splitlines() or ["git failed"])[-1],
241
+ "Check git in the environment image, then retry; set git.mirror = false (or OFFBOX_GIT_MIRROR=false) to run without git.",
242
+ ) from exc
243
+ logger.info("mirrored %s into %s", plan.head, work.name)
244
+ return True
245
+
246
+ def apply(
247
+ self,
248
+ store: StateStore,
249
+ work: Worktree,
250
+ host: HostState,
251
+ project: ProjectConfig,
252
+ *,
253
+ force: bool,
254
+ git: bool = True,
255
+ repository: bool = True,
256
+ ) -> None:
257
+ """Bring copied files and the git mirror up to date; repository=False means /work/.git is gone and must be pushed again."""
258
+ recorded = store.read().envs.get(work.name)
259
+ if recorded is None:
260
+ raise PreconditionError("Environment registration disappeared", "Run offbox up.")
261
+ copied = dict(recorded.copied_files)
262
+ if project.files.copy_:
263
+ with stage("File copies"):
264
+ copied = self.copy_files(work, host, project.files.copy_, recorded.copied_files, force=force)
265
+ mirrored = recorded.mirrored_git
266
+ if project.git.mirror and git:
267
+ with stage("Git mirror"):
268
+ plan = self.plan(work.root, project.git.refs)
269
+ if plan is not None:
270
+ fingerprint = plan.fingerprint()
271
+ skipped = f"no-git:{fingerprint}"
272
+ unchanged = recorded.mirrored_git == fingerprint and repository
273
+ if force or not (unchanged or recorded.mirrored_git == skipped):
274
+ mirrored = fingerprint if self.mirror(work, host, plan) else skipped
275
+ if mirrored == skipped:
276
+ console.warn(
277
+ "The environment image has no git, so the git mirror was skipped. "
278
+ "Fix: add git to the image or set git.mirror = false."
279
+ )
280
+ if copied == recorded.copied_files and mirrored == recorded.mirrored_git:
281
+ return
282
+ with store.transaction() as state:
283
+ current = state.envs.get(work.name)
284
+ if current is not None and current.host == host.name:
285
+ current.copied_files = copied
286
+ current.mirrored_git = mirrored
offbox/ssh.py ADDED
@@ -0,0 +1,179 @@
1
+ import os
2
+ import re
3
+ import shlex
4
+ import stat
5
+ import tempfile
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from offbox import console
10
+ from offbox.errors import ConnectionError_, PreconditionError
11
+ from offbox.paths import Paths
12
+ from offbox.providers import validate_name
13
+ from offbox.runner import Runner
14
+ from offbox.state import HostState, State
15
+
16
+
17
+ def master_pid(runner: Runner, name: str) -> int | None:
18
+ validate_name(name)
19
+ result = runner.run(["ssh", "-O", "check", f"offbox-{name}"], check=False, env={"LC_ALL": "C"}, timeout=10)
20
+ if result.returncode:
21
+ diagnostics = [line.strip() for line in result.stderr.splitlines() if line.strip()]
22
+ if diagnostics and all(
23
+ re.fullmatch(r"Control socket connect\(.+\): (No such file or directory|Connection refused)", line) for line in diagnostics
24
+ ):
25
+ return None
26
+ raise ConnectionError_(
27
+ f"Cannot check SSH master for {name}: {result.stderr.strip()}",
28
+ f"Check SSH configuration and control socket permissions for offbox-{name}, then retry.",
29
+ )
30
+ match = re.search(r"Master running \(pid=([1-9][0-9]*)\)", result.stderr + "\n" + result.stdout)
31
+ if match is None:
32
+ raise PreconditionError("Cannot identify the SSH master", "Check OpenSSH multiplexing output and retry.")
33
+ return int(match[1])
34
+
35
+
36
+ def close_master(runner: Runner, name: str) -> None:
37
+ if master_pid(runner, name) is None:
38
+ return
39
+ result = runner.run(["ssh", "-O", "exit", f"offbox-{name}"], check=False, env={"LC_ALL": "C"}, timeout=10)
40
+ if result.returncode:
41
+ raise ConnectionError_(
42
+ f"Cannot close SSH master for {name}: {result.stderr.strip()}", f"Run ssh -O exit offbox-{name}, then retry cleanup."
43
+ )
44
+
45
+
46
+ def write_private(path: Path, content: str) -> None:
47
+ temporary: Path | None = None
48
+ try:
49
+ destination = path.resolve() if path.is_symlink() else path
50
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
51
+ mode = stat.S_IMODE(destination.stat().st_mode) if destination.exists() else 0o600
52
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=destination.parent, delete=False) as target:
53
+ temporary = Path(target.name)
54
+ target.write(content)
55
+ target.flush()
56
+ os.fsync(target.fileno())
57
+ temporary.chmod(mode)
58
+ os.replace(temporary, destination)
59
+ except OSError as exc:
60
+ raise PreconditionError(f"Cannot write {path}", "Check directory permissions and free disk space.") from exc
61
+ finally:
62
+ if temporary is not None:
63
+ temporary.unlink(missing_ok=True)
64
+
65
+
66
+ def validate_address(address: str) -> str:
67
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.:-]*", address) is None:
68
+ raise PreconditionError("Invalid SSH address", "Use a hostname or IP address without SSH options or whitespace.")
69
+ return address
70
+
71
+
72
+ def validate_user(user: str) -> str:
73
+ if re.fullmatch(r"[a-z_][a-z0-9_.-]*", user) is None:
74
+ raise PreconditionError("Invalid SSH user", "Use a valid SSH username.")
75
+ return user
76
+
77
+
78
+ def quote_path(value: Path) -> str:
79
+ text = str(value)
80
+ if any(char in text for char in "\r\n\0"):
81
+ raise PreconditionError("Unsafe path in SSH configuration", "Choose a path without control characters.")
82
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
83
+
84
+
85
+ def include_line(paths: Paths) -> str:
86
+ return f"Include {quote_path(paths.config / 'ssh_config')}"
87
+
88
+
89
+ def remove_include(paths: Paths, config: Path | None = None) -> None:
90
+ target = config or Path.home() / ".ssh/config"
91
+ try:
92
+ previous = target.read_bytes()
93
+ except FileNotFoundError:
94
+ return
95
+ except OSError as exc:
96
+ raise PreconditionError(f"Cannot read SSH configuration {target}", "Check SSH configuration permissions.") from exc
97
+ owned = include_line(paths).encode()
98
+ remaining = b"".join(line for line in previous.splitlines(keepends=True) if line.rstrip(b"\r\n") != owned)
99
+ if remaining != previous:
100
+ try:
101
+ content = remaining.decode("utf-8")
102
+ except UnicodeError as exc:
103
+ raise PreconditionError(f"Cannot read SSH configuration {target}", "Correct its UTF-8 encoding.") from exc
104
+ write_private(target, content)
105
+
106
+
107
+ def ensure_include(paths: Paths, config: Path | None = None) -> None:
108
+ target = config or Path.home() / ".ssh/config"
109
+ include = include_line(paths)
110
+ try:
111
+ previous = target.read_text(encoding="utf-8") if target.exists() else ""
112
+ except (OSError, UnicodeError) as exc:
113
+ raise PreconditionError(f"Cannot read SSH configuration {target}", "Check SSH configuration permissions.") from exc
114
+ remaining = [line for line in previous.splitlines(keepends=True) if line.strip() != include]
115
+ updated = include + "\n" + "".join(remaining)
116
+ if updated != previous:
117
+ write_private(target, updated)
118
+ console.info(f"Added offbox Include at the top of {target}")
119
+
120
+
121
+ def default_ssh_key() -> Path:
122
+ """~/.ssh/id_ed25519 unless only the dedicated ~/.ssh/offbox_ed25519 that setup creates exists."""
123
+ directory = Path.home() / ".ssh"
124
+ default = directory / "id_ed25519"
125
+ dedicated = directory / "offbox_ed25519"
126
+ if not default.exists() and not default.with_name("id_ed25519.pub").exists() and dedicated.exists():
127
+ return dedicated
128
+ return default
129
+
130
+
131
+ def sync_config(paths: Paths, state: State) -> None:
132
+ control = paths.ensure_control()
133
+ blocks: list[str] = []
134
+ for name, host in sorted(state.hosts.items()):
135
+ validate_name(name)
136
+ if not host.address:
137
+ continue
138
+ validate_address(host.address)
139
+ validate_user(host.ssh_user)
140
+ key = (host.ssh_key or Path.home() / ".ssh/id_ed25519").expanduser()
141
+ blocks.append(
142
+ f"Host offbox-{name}\n"
143
+ f" HostName {host.address}\n"
144
+ f" User {host.ssh_user}\n"
145
+ f" Port {host.ssh_port}\n"
146
+ f" IdentityFile {quote_path(key)}\n"
147
+ f" UserKnownHostsFile {quote_path(host.known_hosts_path)}\n"
148
+ f" HostKeyAlias offbox-{name}\n"
149
+ " HostKeyAlgorithms ssh-ed25519\n"
150
+ " StrictHostKeyChecking yes\n"
151
+ " BatchMode yes\n"
152
+ " IdentitiesOnly yes\n"
153
+ " ForwardAgent no\n"
154
+ " ForwardX11 no\n"
155
+ " ControlMaster auto\n"
156
+ f" ControlPath {quote_path(control / '%C').replace('%%C', '%C')}\n"
157
+ " ControlPersist 30m\n"
158
+ " ServerAliveInterval 15\n"
159
+ " ServerAliveCountMax 4\n"
160
+ )
161
+ write_private(paths.config / "ssh_config", "\n".join(blocks))
162
+
163
+
164
+ def ssh_argv(name: str, argv: Sequence[str] = (), *, options: Sequence[str] = ()) -> list[str]:
165
+ validate_name(name)
166
+ command = ["ssh", *options, f"offbox-{name}"]
167
+ return [*command, "--", shlex.join(argv)] if argv else command
168
+
169
+
170
+ def known_host(name: str, public_key: str) -> str:
171
+ validate_name(name)
172
+ fields = public_key.strip().split()
173
+ if len(fields) < 2 or fields[0] != "ssh-ed25519" or re.fullmatch(r"[A-Za-z0-9+/=]+", fields[1]) is None:
174
+ raise PreconditionError("Invalid ed25519 SSH public key", "Use an ed25519 host key and retry.")
175
+ return f"offbox-{name} {fields[0]} {fields[1]}\n"
176
+
177
+
178
+ def host_key_path(host: HostState) -> Path:
179
+ return host.known_hosts_path.parent / "ssh_host_ed25519_key"