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