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/host/create.py ADDED
@@ -0,0 +1,198 @@
1
+ import logging
2
+ import shutil
3
+ from collections.abc import Callable
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from pydantic import field_validator
8
+
9
+ from offbox import console
10
+ from offbox.config import HostPreset, Model, UserConfig, load_config
11
+ from offbox.drivers.base import HostVariables
12
+ from offbox.drivers.tofu import TofuDriver
13
+ from offbox.errors import CommandFailed, OffboxError, PreconditionError, ProviderInterrupted
14
+ from offbox.host import host_operation, public_key
15
+ from offbox.host.cloudinit import generate_host_key, render_cloud_init
16
+ from offbox.host.ready import Clock, SystemClock, tcp_probe, wait_ready
17
+ from offbox.paths import Paths
18
+ from offbox.providers import copy_provider, credentials_env, discover_provider, validate_name
19
+ from offbox.runner import Runner
20
+ from offbox.ssh import close_master, default_ssh_key, ensure_include, host_key_path, known_host, ssh_argv, sync_config, write_private
21
+ from offbox.state import HostState, StateStore
22
+ from offbox.timings import stage
23
+ from offbox.tools import ToolDiscovery
24
+
25
+ logger = logging.getLogger(__name__)
26
+ _NAME_TAKEN = ("uniqueness_error", "alreadyExists", "already exists")
27
+
28
+
29
+ def _name_taken(exc: CommandFailed) -> bool:
30
+ return any(marker in exc.stderr_tail for marker in _NAME_TAKEN)
31
+
32
+
33
+ class CreateOptions(Model):
34
+ name: str = "main"
35
+ provider: str | None = None
36
+ size: str | None = None
37
+ region: str | None = None
38
+ ssh_key: Path | None = None
39
+ auto: bool = False
40
+ idle_seconds: int | None = None
41
+
42
+ @field_validator("name")
43
+ @classmethod
44
+ def check_name(cls, value: str) -> str:
45
+ return validate_name(value)
46
+
47
+
48
+ def _remove_unrecorded(directory: Path) -> None:
49
+ try:
50
+ shutil.rmtree(directory)
51
+ except OSError:
52
+ logger.exception("Cannot remove unrecorded host directory %s", directory)
53
+ console.warn(f"Remove {directory} before retrying host creation.")
54
+
55
+
56
+ def create_host_locked(
57
+ paths: Paths,
58
+ runner: Runner,
59
+ options: CreateOptions,
60
+ *,
61
+ executable: Path | None = None,
62
+ clock: Clock | None = None,
63
+ probe: Callable[[str, int, float], bool] = tcp_probe,
64
+ ssh_config: Path | None = None,
65
+ ) -> HostState:
66
+ store = StateStore(paths.state)
67
+ if options.name in store.read().hosts:
68
+ raise PreconditionError(f"Host {options.name} already exists", f"Run offbox host destroy {options.name} or choose another name.")
69
+ timer = clock or SystemClock()
70
+ started = timer.now()
71
+ user = load_config(paths.user_config, UserConfig)
72
+ preset = user.hosts.get(options.name, HostPreset())
73
+ provider = discover_provider(options.provider or preset.provider, paths)
74
+ if provider.manifest.driver != "tofu":
75
+ raise PreconditionError("This provider registers existing hosts", "Run offbox host init USER@ADDRESS.")
76
+ credentials = credentials_env(provider.manifest, paths)
77
+ key = (options.ssh_key or preset.ssh_key or default_ssh_key()).expanduser().resolve()
78
+ authorized_key = public_key(runner, key)
79
+ binary = executable or ToolDiscovery(runner, paths.cache).check("tofu").path
80
+ defaults = provider.manifest.defaults
81
+ size = options.size or preset.size or defaults.size
82
+ region = options.region or preset.region or defaults.region
83
+ image = preset.image or defaults.image
84
+ if not size or not region or not image:
85
+ raise PreconditionError("Provider size, region and image are required", "Set provider defaults or a matching host preset.")
86
+ directory = paths.state / "hosts" / options.name
87
+ workspace = directory / "tofu"
88
+ created = recorded = False
89
+ try:
90
+ with store.transaction() as state:
91
+ if options.name in state.hosts:
92
+ raise PreconditionError(
93
+ f"Host {options.name} already exists", f"Run offbox host destroy {options.name} or choose another name."
94
+ )
95
+ if directory.exists():
96
+ raise PreconditionError(
97
+ f"Host directory already exists: {directory}",
98
+ "Inspect the existing directory before removing it or choose another host name.",
99
+ )
100
+ directory.mkdir(parents=True, mode=0o700)
101
+ created = True
102
+ digest = copy_provider(provider, workspace)
103
+ host = HostState(
104
+ name=options.name,
105
+ provider=provider.manifest.name,
106
+ address="",
107
+ ssh_user="offbox",
108
+ created_at=datetime.now(UTC),
109
+ provider_dir=workspace,
110
+ provider_files_hash=digest,
111
+ known_hosts_path=directory / "known_hosts",
112
+ ssh_key=key,
113
+ phase="creating",
114
+ auto=options.auto,
115
+ idle_seconds=options.idle_seconds,
116
+ )
117
+ state.hosts[host.name] = host
118
+ recorded = True
119
+ finally:
120
+ if created and not recorded:
121
+ _remove_unrecorded(directory)
122
+ cleanup = f"Run offbox host destroy {host.name} to clean up; its provider workspace was retained."
123
+ try:
124
+ private, public = generate_host_key(runner, host_key_path(host))
125
+ write_private(host.known_hosts_path, known_host(host.name, public))
126
+ variables = HostVariables(
127
+ name=f"offbox-{host.name}",
128
+ size=size,
129
+ region=region,
130
+ image=image,
131
+ ssh_public_key=authorized_key,
132
+ user_data=render_cloud_init(authorized_key, private, public),
133
+ labels={"offbox": "1", "offbox-host": host.name},
134
+ )
135
+ try:
136
+ output = TofuDriver(runner, workspace, paths.cache, credentials, binary).create(variables)
137
+ except CommandFailed as exc:
138
+ if _name_taken(exc):
139
+ raise PreconditionError(
140
+ f"Cloud resources named offbox-{host.name} already exist in the provider project",
141
+ "Delete them in the provider console or choose another host name, "
142
+ f"then run offbox host destroy {host.name} to clear the local record.",
143
+ ) from exc
144
+ raise
145
+ host.address = output.ip
146
+ host.ssh_user = output.ssh_user
147
+ host.phase = "initializing"
148
+ with store.transaction() as state:
149
+ state.hosts[host.name] = host
150
+ sync_config(paths, state)
151
+ ensure_include(paths, ssh_config)
152
+ wait_ready(host.name, host.address, host.ssh_port, runner, paths.log_file(), clock=timer, probe=probe)
153
+ # Readiness polling can authenticate before bootstrap adds the Docker group.
154
+ # A multiplexed connection retains that login's groups until it is replaced.
155
+ with stage("SSH refresh"):
156
+ close_master(runner, host.name)
157
+ runner.run(ssh_argv(host.name, ["docker", "info", "--format", "{{.ServerVersion}}"]), timeout=10)
158
+ host.phase = "ready"
159
+ with store.transaction() as state:
160
+ state.hosts[host.name] = host
161
+ if state.default_host is None and not host.auto:
162
+ state.default_host = host.name
163
+ except ProviderInterrupted as exc:
164
+ if exc.killed:
165
+ host.create_interrupted = True
166
+ with store.transaction() as state:
167
+ if state.hosts.get(host.name) is not None:
168
+ state.hosts[host.name] = host
169
+ fix = (
170
+ f"Run offbox host destroy {host.name}, then check the provider console for resources labelled "
171
+ f"offbox-host={host.name} and delete any that remain."
172
+ if exc.killed
173
+ else cleanup
174
+ )
175
+ raise OffboxError(f"Creating host {host.name} failed: {exc.message}", fix, exc.exit_code) from exc
176
+ except OffboxError as exc:
177
+ raise OffboxError(f"Creating host {host.name} failed: {exc.message}", cleanup, exc.exit_code) from exc
178
+ except KeyboardInterrupt as exc:
179
+ raise OffboxError(f"Creating host {host.name} was interrupted", cleanup, 130) from exc
180
+ finally:
181
+ host_key_path(host).unlink(missing_ok=True)
182
+ host_key_path(host).with_name(host_key_path(host).name + ".pub").unlink(missing_ok=True)
183
+ console.success(f"Host {host.name}: {host.address} ({timer.now() - started:.1f}s)")
184
+ return host
185
+
186
+
187
+ def create_host(
188
+ paths: Paths,
189
+ runner: Runner,
190
+ options: CreateOptions,
191
+ *,
192
+ executable: Path | None = None,
193
+ clock: Clock | None = None,
194
+ probe: Callable[[str, int, float], bool] = tcp_probe,
195
+ ssh_config: Path | None = None,
196
+ ) -> HostState:
197
+ with host_operation(paths, options.name):
198
+ return create_host_locked(paths, runner, options, executable=executable, clock=clock, probe=probe, ssh_config=ssh_config)
offbox/host/destroy.py ADDED
@@ -0,0 +1,151 @@
1
+ import logging
2
+ import re
3
+ import shutil
4
+ from collections.abc import Callable
5
+ from functools import partial
6
+ from pathlib import Path
7
+
8
+ from offbox import console
9
+ from offbox.drivers.none import NoneDriver
10
+ from offbox.drivers.tofu import TofuDriver
11
+ from offbox.errors import OffboxError, PreconditionError, ProviderInterrupted, UsageError
12
+ from offbox.host import host_operation, select_host
13
+ from offbox.host.activity import forget as forget_activity
14
+ from offbox.host.usage import describe, exclusive_usage, holders
15
+ from offbox.host.warnings import warn_interrupted
16
+ from offbox.naming import Worktree
17
+ from offbox.paths import Paths
18
+ from offbox.providers import credentials_env, load_provider, validate_name
19
+ from offbox.runner import Runner
20
+ from offbox.ssh import close_master, sync_config
21
+ from offbox.state import EnvState, HostState, StateStore
22
+ from offbox.sync import SyncManager
23
+ from offbox.tools import ToolDiscovery
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _best_effort(action: Callable[[], None], warning: str) -> None:
29
+ try:
30
+ action()
31
+ except OffboxError as exc:
32
+ logger.warning("%s", warning, exc_info=True)
33
+ console.warn(f"{warning} ({exc.message})")
34
+
35
+
36
+ def _release_local(runner: Runner, cache: Path, host: HostState, envs: dict[str, EnvState], *, strict: bool) -> None:
37
+ sync = SyncManager(runner, cache=cache)
38
+ for env in envs.values():
39
+ work = Worktree(root=env.worktree_path, name=env.name)
40
+ if strict:
41
+ sync.terminate(work, host)
42
+ else:
43
+ _best_effort(
44
+ partial(sync.terminate, work, host),
45
+ f"Cannot stop source sync for {env.name}; afterwards run mutagen sync list and terminate offbox-{env.name} if it remains",
46
+ )
47
+ if not host.address:
48
+ return
49
+ if strict:
50
+ close_master(runner, host.name)
51
+ else:
52
+ _best_effort(
53
+ partial(close_master, runner, host.name),
54
+ f"Cannot close the SSH master for offbox-{host.name}; stop that local ssh process manually",
55
+ )
56
+
57
+
58
+ def _destroy_cloud(runner: Runner, host: HostState, workspace: Path, cache: Path, credentials: dict[str, str], binary: Path) -> None:
59
+ try:
60
+ TofuDriver(runner, workspace, cache, credentials, binary).destroy()
61
+ except ProviderInterrupted as exc:
62
+ fix = f"Run offbox host destroy {host.name} again."
63
+ if exc.killed:
64
+ fix = (
65
+ f"Run offbox host destroy {host.name} again, then check the provider console for resources labelled "
66
+ f"offbox-host={host.name} and delete any that remain."
67
+ )
68
+ raise ProviderInterrupted(f"Destroying host {host.name} was interrupted", fix, killed=exc.killed) from exc
69
+
70
+
71
+ def destroy_host_locked(
72
+ paths: Paths, runner: Runner, name: str | None = None, *, executable: Path | None = None, forget: bool = False
73
+ ) -> None:
74
+ host = select_host(paths, name)
75
+ validate_name(host.name)
76
+ directory = paths.state / "hosts" / host.name
77
+ expected = directory / ("provider" if host.provider == "ssh" else "tofu")
78
+ if host.provider_dir is None or host.provider_dir.resolve() != expected.resolve() or directory.is_symlink() or expected.is_symlink():
79
+ raise PreconditionError(
80
+ "Host provider workspace is missing or unsafe", "Restore the host's saved provider workspace before destroying it."
81
+ )
82
+ provider = load_provider(host.provider_dir)
83
+ existing = provider.manifest.driver == "none"
84
+ if forget and not existing:
85
+ raise UsageError(
86
+ f"Host {host.name} is a cloud host; --forget would leave it running and billing",
87
+ f"Run offbox host destroy {host.name} --yes to delete it.",
88
+ )
89
+ credentials = credentials_env(provider.manifest, paths)
90
+ binary = None if existing else executable or ToolDiscovery(runner, paths.cache).check("tofu").path
91
+ store = StateStore(paths.state)
92
+ snapshot = store.read()
93
+ if snapshot.hosts.get(host.name) != host:
94
+ raise PreconditionError("Host changed while preparing destruction", "Run host destroy again after other operations finish.")
95
+ envs = {name: env for name, env in snapshot.envs.items() if env.host == host.name}
96
+ for env in envs.values():
97
+ if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,36}", env.name) is None:
98
+ raise PreconditionError("Invalid stored environment name", "Repair the environment state before destroying its host.")
99
+ _release_local(runner, paths.cache, host, envs, strict=existing and not forget)
100
+ if existing and not forget:
101
+ NoneDriver(runner, host).destroy()
102
+ _best_effort(
103
+ partial(close_master, runner, host.name),
104
+ f"Cannot close the SSH master for offbox-{host.name}; stop that local ssh process manually",
105
+ )
106
+ elif not existing:
107
+ if binary is None:
108
+ raise PreconditionError("Provider tool is unavailable", "Install OpenTofu or Terraform and retry.")
109
+ _destroy_cloud(runner, host, host.provider_dir, paths.cache, credentials, binary)
110
+ with store.transaction() as state:
111
+ if state.hosts.get(host.name) != host or {name: env for name, env in state.envs.items() if env.host == host.name} != envs:
112
+ logger.warning("host %s changed during destruction", host.name)
113
+ console.warn(
114
+ f"Host {host.name} changed while it was being destroyed; removing its record and environments anyway. "
115
+ "Review offbox list and mutagen sync list for environments added meanwhile."
116
+ )
117
+ state.hosts.pop(host.name, None)
118
+ state.envs = {name: env for name, env in state.envs.items() if env.host != host.name}
119
+ if state.default_host == host.name:
120
+ state.default_host = next((name for name, item in state.hosts.items() if item.phase == "ready" and not item.auto), None)
121
+ sync_config(paths, state)
122
+ for env_name in envs:
123
+ forget_activity(paths, env_name)
124
+ if host.create_interrupted:
125
+ warn_interrupted(paths, host.name)
126
+ try:
127
+ shutil.rmtree(directory)
128
+ except OSError as exc:
129
+ raise PreconditionError(
130
+ f"Host destroyed, but cannot remove {directory}", "Remove the obsolete local host directory manually."
131
+ ) from exc
132
+ if forget:
133
+ console.warn(
134
+ f"Forgot host {host.name} without contacting {host.address or 'the server'}. Still on the server: "
135
+ "offbox-labelled Docker containers and volumes, the offbox Docker network, /srv/offbox, /var/lib/offbox, "
136
+ "the offbox user, /home/offbox/.ssh/authorized_keys and its passwordless sudo rule /etc/sudoers.d/offbox."
137
+ )
138
+ console.success(f"Forgot host {host.name}")
139
+ return
140
+ console.success(f"Destroyed host {host.name}")
141
+
142
+
143
+ def destroy_host(paths: Paths, runner: Runner, name: str | None = None, *, executable: Path | None = None, forget: bool = False) -> None:
144
+ host = select_host(paths, name)
145
+ with exclusive_usage(paths, host.name) as free:
146
+ if not free:
147
+ raise PreconditionError(
148
+ f"Host {host.name} is in use by: {describe(holders(paths, host.name))}", "Wait for those commands to finish, then retry."
149
+ )
150
+ with host_operation(paths, host.name):
151
+ destroy_host_locked(paths, runner, host.name, executable=executable, forget=forget)
offbox/host/init.py ADDED
@@ -0,0 +1,196 @@
1
+ import base64
2
+ import binascii
3
+ import hashlib
4
+ import re
5
+ import shlex
6
+ from collections.abc import Callable
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from pydantic import Field, field_validator
11
+
12
+ from offbox import DEFAULT_IMAGE, __version__, console
13
+ from offbox.config import Model
14
+ from offbox.errors import OffboxError, PreconditionError, UsageError
15
+ from offbox.host import host_operation, public_key
16
+ from offbox.host.cloudinit import bootstrap_script
17
+ from offbox.host.ready import Clock, tcp_probe, wait_ready
18
+ from offbox.paths import Paths
19
+ from offbox.providers import copy_provider, discover_provider, validate_name
20
+ from offbox.runner import Runner
21
+ from offbox.ssh import default_ssh_key, ensure_include, known_host, quote_path, sync_config, validate_address, validate_user, write_private
22
+ from offbox.state import HostState, StateStore
23
+
24
+
25
+ class InitOptions(Model):
26
+ target: str
27
+ name: str = "main"
28
+ port: int = Field(default=22, ge=1, le=65535)
29
+ ssh_key: Path | None = None
30
+ accept_host_key: bool = False
31
+ host_key_fingerprint: str | None = None
32
+
33
+ @field_validator("name")
34
+ @classmethod
35
+ def check_name(cls, value: str) -> str:
36
+ return validate_name(value)
37
+
38
+ @field_validator("host_key_fingerprint")
39
+ @classmethod
40
+ def check_fingerprint(cls, value: str | None) -> str | None:
41
+ if value is not None and re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", value) is None:
42
+ raise UsageError("Invalid host key fingerprint", "Pass the SHA256:... value printed by ssh-keygen -lf on the server.")
43
+ return value
44
+
45
+
46
+ def _init_host(
47
+ paths: Paths,
48
+ runner: Runner,
49
+ options: InitOptions,
50
+ *,
51
+ confirm: Callable[[str], bool] | None = None,
52
+ ssh_config: Path | None = None,
53
+ clock: Clock | None = None,
54
+ probe: Callable[[str, int, float], bool] = tcp_probe,
55
+ ) -> HostState:
56
+ if not options.accept_host_key and options.host_key_fingerprint is None and confirm is None:
57
+ raise PreconditionError(
58
+ "Host key verification needs a TTY, --host-key-fingerprint or --accept-host-key",
59
+ "Pass --host-key-fingerprint SHA256:... from ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server.",
60
+ )
61
+ if options.target.count("@") != 1:
62
+ raise PreconditionError("Expected USER@ADDRESS", "Run offbox host init USER@ADDRESS.")
63
+ owner, address = options.target.split("@", 1)
64
+ validate_user(owner)
65
+ address = validate_address(address.strip("[]"))
66
+ key = (options.ssh_key or default_ssh_key()).expanduser().resolve()
67
+ scan = runner.run(["ssh-keyscan", "-T", "5", "-p", str(options.port), "-t", "ed25519", address], timeout=10)
68
+ keys = {
69
+ " ".join(line.split()[1:3])
70
+ for line in scan.stdout.splitlines()
71
+ if line.strip() and not line.startswith("#") and len(line.split()) >= 3
72
+ }
73
+ if len(keys) != 1:
74
+ raise PreconditionError("Cannot determine a unique ed25519 host key", "Check the SSH address and its advertised host keys.")
75
+ scanned_key = keys.pop()
76
+ pinned = known_host(options.name, scanned_key)
77
+ try:
78
+ digest = base64.b64encode(hashlib.sha256(base64.b64decode(scanned_key.split()[1], validate=True)).digest()).decode().rstrip("=")
79
+ except (ValueError, binascii.Error) as exc:
80
+ raise PreconditionError("Invalid SSH host key", "Check the server's ed25519 key.") from exc
81
+ fingerprint = f"SHA256:{digest}"
82
+ console.info(f"Host {address} fingerprint {fingerprint}")
83
+ if options.host_key_fingerprint is not None:
84
+ if options.host_key_fingerprint != fingerprint:
85
+ raise PreconditionError(
86
+ f"Host key fingerprint mismatch for {address}: expected {options.host_key_fingerprint}, got {fingerprint}",
87
+ "Verify the server identity with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server before retrying.",
88
+ )
89
+ elif options.accept_host_key:
90
+ console.warn(f"Trusting {fingerprint} without verification; prefer --host-key-fingerprint {fingerprint}.")
91
+ elif confirm is None or not confirm(f"Trust {fingerprint} for {address}?"):
92
+ raise PreconditionError("Host key was not accepted", "Verify the fingerprint and rerun offbox host init.")
93
+ authorized_key = public_key(runner, key)
94
+ directory = paths.state / "hosts" / options.name
95
+ provider = discover_provider("ssh", paths)
96
+ if provider.manifest.driver != "none":
97
+ raise PreconditionError("The ssh provider must use driver none", "Correct the ssh provider manifest.")
98
+ store = StateStore(paths.state)
99
+ with store.transaction() as state:
100
+ previous = state.hosts.get(options.name)
101
+ if previous is not None:
102
+ if previous.provider != "ssh" or previous.address != address or previous.ssh_port != options.port or previous.phase == "ready":
103
+ raise PreconditionError(
104
+ f"Host {options.name} already exists", f"Run offbox host destroy {options.name} or choose another name."
105
+ )
106
+ if previous.known_hosts_path.read_text(encoding="utf-8") != pinned:
107
+ raise PreconditionError(
108
+ "The existing host key has changed", "Verify the server identity before changing its stored host key."
109
+ )
110
+ host = previous
111
+ host.ssh_key = key
112
+ else:
113
+ if directory.exists():
114
+ raise PreconditionError(
115
+ f"Host directory already exists: {directory}", "Inspect the existing directory or choose another name."
116
+ )
117
+ directory.mkdir(parents=True, mode=0o700)
118
+ workspace = directory / "provider"
119
+ digest_files = copy_provider(provider, workspace)
120
+ host = HostState(
121
+ name=options.name,
122
+ provider="ssh",
123
+ address=address,
124
+ ssh_user="offbox",
125
+ ssh_port=options.port,
126
+ created_at=datetime.now(UTC),
127
+ provider_dir=workspace,
128
+ provider_files_hash=digest_files,
129
+ known_hosts_path=directory / "known_hosts",
130
+ ssh_key=key,
131
+ phase="initializing",
132
+ )
133
+ state.hosts[host.name] = host
134
+ write_private(host.known_hosts_path, pinned)
135
+ sync_config(paths, state)
136
+ ensure_include(paths, ssh_config)
137
+ command = ["sudo", "-n", "env", "OFFBOX_HARDEN=0", f"OFFBOX_VERSION={__version__}", "sh", "-s", "--", DEFAULT_IMAGE, authorized_key]
138
+ try:
139
+ runner.run(
140
+ [
141
+ "ssh",
142
+ "-o",
143
+ "BatchMode=yes",
144
+ "-o",
145
+ "StrictHostKeyChecking=yes",
146
+ "-o",
147
+ "HostKeyAlgorithms=ssh-ed25519",
148
+ "-o",
149
+ f"HostKeyAlias=offbox-{host.name}",
150
+ "-o",
151
+ f"UserKnownHostsFile={quote_path(host.known_hosts_path)}",
152
+ "-o",
153
+ "IdentitiesOnly=yes",
154
+ "-o",
155
+ "ForwardAgent=no",
156
+ "-o",
157
+ "ForwardX11=no",
158
+ "-i",
159
+ str(key),
160
+ "-p",
161
+ str(options.port),
162
+ f"{owner}@{address}",
163
+ "--",
164
+ shlex.join(command),
165
+ ],
166
+ input=bootstrap_script(),
167
+ timeout=900,
168
+ )
169
+ wait_ready(host.name, host.address, host.ssh_port, runner, paths.log_file(), clock=clock, probe=probe)
170
+ host.phase = "ready"
171
+ with store.transaction() as state:
172
+ state.hosts[host.name] = host
173
+ if state.default_host is None:
174
+ state.default_host = host.name
175
+ except OffboxError as exc:
176
+ raise OffboxError(
177
+ f"Initialising host {host.name} failed: {exc.message}",
178
+ "Correct the reported problem and rerun the same host init command; its registration was retained.",
179
+ exc.exit_code,
180
+ ) from exc
181
+ console.success(f"Host {host.name}: {host.address}")
182
+ return host
183
+
184
+
185
+ def init_host(
186
+ paths: Paths,
187
+ runner: Runner,
188
+ options: InitOptions,
189
+ *,
190
+ confirm: Callable[[str], bool] | None = None,
191
+ ssh_config: Path | None = None,
192
+ clock: Clock | None = None,
193
+ probe: Callable[[str, int, float], bool] = tcp_probe,
194
+ ) -> HostState:
195
+ with host_operation(paths, options.name):
196
+ return _init_host(paths, runner, options, confirm=confirm, ssh_config=ssh_config, clock=clock, probe=probe)
offbox/host/ready.py ADDED
@@ -0,0 +1,106 @@
1
+ import logging
2
+ import socket
3
+ import time
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import NoReturn, Protocol
7
+
8
+ from offbox.errors import ConnectionError_, OffboxError, ProviderError
9
+ from offbox.runner import Runner
10
+ from offbox.ssh import ssh_argv
11
+ from offbox.timings import measured
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ BOOTSTRAP_FAILED = 7
16
+ BOOTSTRAP_TAIL_LINES = 15
17
+ READY_PROBE = (
18
+ "test -f /var/lib/offbox/ready && exit 0; "
19
+ f"test -f /var/lib/offbox/failed && {{ head -c 2000 /var/lib/offbox/failed; exit {BOOTSTRAP_FAILED}; }}; "
20
+ "exit 1"
21
+ )
22
+
23
+
24
+ class Clock(Protocol):
25
+ def now(self) -> float: ...
26
+ def sleep(self, seconds: float) -> None: ...
27
+
28
+
29
+ class SystemClock:
30
+ def now(self) -> float:
31
+ return time.monotonic()
32
+
33
+ def sleep(self, seconds: float) -> None:
34
+ time.sleep(seconds)
35
+
36
+
37
+ def tcp_probe(address: str, port: int, timeout: float) -> bool:
38
+ try:
39
+ with socket.create_connection((address, port), timeout=timeout):
40
+ return True
41
+ except OSError:
42
+ return False
43
+
44
+
45
+ @measured("Host readiness")
46
+ def wait_ready(
47
+ name: str,
48
+ address: str,
49
+ port: int,
50
+ runner: Runner,
51
+ log_path: Path,
52
+ *,
53
+ clock: Clock | None = None,
54
+ probe: Callable[[str, int, float], bool] = tcp_probe,
55
+ tcp_timeout: float = 300,
56
+ ready_timeout: float = 900,
57
+ ) -> None:
58
+ timer = clock or SystemClock()
59
+
60
+ def diagnostics() -> str:
61
+ try:
62
+ diagnostic = runner.run(
63
+ ssh_argv(name, ["sudo", "-n", "tail", "-n", "40", "/var/log/cloud-init-output.log"], options=["-o", "ConnectTimeout=3"]),
64
+ check=False,
65
+ timeout=10,
66
+ )
67
+ logger.error("host bootstrap diagnostics: %s\n%s", diagnostic.stdout, diagnostic.stderr)
68
+ return diagnostic.stdout
69
+ except OffboxError:
70
+ logger.exception("Cannot retrieve bootstrap diagnostics")
71
+ return ""
72
+
73
+ def fail() -> NoReturn:
74
+ diagnostics()
75
+ raise ConnectionError_(f"Host {name} did not become ready", f"Run offbox host ssh {name}; inspect {log_path}.")
76
+
77
+ def failed(marker: str) -> NoReturn:
78
+ tail = "\n".join(diagnostics().splitlines()[-BOOTSTRAP_TAIL_LINES:])
79
+ details = marker.strip() or "no details recorded"
80
+ raise ProviderError(
81
+ f"Host {name} bootstrap failed ({details})" + (f"\n{tail}" if tail else ""),
82
+ f"Run offbox host ssh {name} and inspect /var/log/cloud-init-output.log and {log_path}.",
83
+ )
84
+
85
+ deadline = timer.now() + tcp_timeout
86
+ while not probe(address, port, min(3, max(0.1, deadline - timer.now()))):
87
+ if timer.now() >= deadline:
88
+ fail()
89
+ timer.sleep(min(3, max(0, deadline - timer.now())))
90
+ deadline = timer.now() + ready_timeout
91
+ while timer.now() < deadline:
92
+ try:
93
+ result = runner.run(
94
+ ssh_argv(name, ["sh", "-c", READY_PROBE], options=["-o", "ConnectTimeout=3"]),
95
+ check=False,
96
+ timeout=min(10, deadline - timer.now()),
97
+ )
98
+ except OffboxError:
99
+ logger.debug("Host SSH not ready", exc_info=True)
100
+ else:
101
+ if result.returncode == 0:
102
+ return
103
+ if result.returncode == BOOTSTRAP_FAILED:
104
+ failed(result.stdout)
105
+ timer.sleep(min(3, max(0, deadline - timer.now())))
106
+ fail()