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/project.py ADDED
@@ -0,0 +1,187 @@
1
+ import os
2
+ import tomllib
3
+ import types
4
+ import typing
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
10
+
11
+ from offbox.config import Model, ProjectConfig, UserConfig, load_config
12
+ from offbox.errors import PreconditionError
13
+ from offbox.paths import Paths
14
+
15
+ PROJECT_FILE = "offbox.toml"
16
+ LOCAL_FILE = "offbox.local.toml"
17
+ Source = Literal["default", "user", "project", "local", "env"]
18
+ type Layer = dict[str, dict[str, JsonValue]]
19
+ _JSON = TypeAdapter[JsonValue](JsonValue)
20
+ _LAYER = TypeAdapter[Layer](dict[str, dict[str, JsonValue]])
21
+ _EXCLUSIVE = {"env.image": ("env.dockerfile",), "env.dockerfile": ("env.image",)}
22
+
23
+
24
+ class SettingKey(Model):
25
+ section: str
26
+ field: str
27
+ is_list: bool
28
+ textual: bool
29
+
30
+
31
+ class Setting(Model):
32
+ key: str
33
+ value: JsonValue
34
+ source: Source
35
+ env: str
36
+
37
+
38
+ class ConfigFiles(Model):
39
+ user: Path
40
+ project: Path
41
+ local: Path
42
+
43
+
44
+ class ConfigReport(Model):
45
+ files: ConfigFiles
46
+ settings: list[Setting]
47
+
48
+
49
+ class ResolvedProject(Model):
50
+ config: ProjectConfig
51
+ sources: dict[str, Source]
52
+
53
+
54
+ def setting_keys() -> dict[str, SettingKey]:
55
+ """Every project setting as section.key, with its TOML section and field name."""
56
+ keys: dict[str, SettingKey] = {}
57
+ for section, info in ProjectConfig.model_fields.items():
58
+ model = info.annotation
59
+ if not (isinstance(model, type) and issubclass(model, BaseModel)):
60
+ raise PreconditionError(f"Invalid project section {section}", "Report this offbox bug.")
61
+ for name, field in model.model_fields.items():
62
+ key = field.alias or name
63
+ is_list = typing.get_origin(field.annotation) is list
64
+ scalar = typing.get_args(field.annotation)[0] if is_list else field.annotation
65
+ members = typing.get_args(scalar) if typing.get_origin(scalar) in (types.UnionType, typing.Union) else (scalar,)
66
+ textual = any(member in (str, Path) for member in members)
67
+ keys[f"{section}.{key}"] = SettingKey(section=section, field=key, is_list=is_list, textual=textual)
68
+ return keys
69
+
70
+
71
+ def env_name(key: str) -> str:
72
+ return "OFFBOX_" + key.upper().replace(".", "_")
73
+
74
+
75
+ def parse_value(raw: str) -> JsonValue:
76
+ """A TOML value such as true, 2, "8g" or [".env"]; text that is not a TOML value is taken as a plain string."""
77
+ try:
78
+ value: object = tomllib.loads(f"value = {raw}")["value"]
79
+ return _JSON.validate_python(value, strict=True)
80
+ except (tomllib.TOMLDecodeError, ValidationError):
81
+ return raw
82
+
83
+
84
+ def _locations(exc: ValidationError) -> str:
85
+ return ", ".join(".".join(str(part) for part in error["loc"]) or "<root>" for error in exc.errors(include_input=False))
86
+
87
+
88
+ def _validate(layer: Layer, origin: str) -> None:
89
+ try:
90
+ ProjectConfig.model_validate(layer)
91
+ except ValidationError as exc:
92
+ raise PreconditionError(f"Invalid configuration in {origin}: {_locations(exc)}", f"Correct these keys in {origin}.") from exc
93
+
94
+
95
+ def read_layer(path: Path) -> Layer:
96
+ try:
97
+ with path.open("rb") as source:
98
+ data: object = tomllib.load(source)
99
+ except FileNotFoundError:
100
+ return {}
101
+ except (tomllib.TOMLDecodeError, UnicodeError, OSError) as exc:
102
+ raise PreconditionError(f"Cannot read configuration {path}", f"Fix the TOML syntax and permissions in {path}.") from exc
103
+ try:
104
+ layer = _LAYER.validate_python(data, strict=True)
105
+ except ValidationError as exc:
106
+ raise PreconditionError(
107
+ f"Invalid configuration in {path}: {_locations(exc)}", f"Use [section] tables with plain TOML values in {path}."
108
+ ) from exc
109
+ _validate(layer, str(path))
110
+ return layer
111
+
112
+
113
+ def env_layer(environ: Mapping[str, str]) -> Layer:
114
+ keys = setting_keys()
115
+ names = {env_name(key): setting for key, setting in keys.items()}
116
+ prefixes = {f"OFFBOX_{setting.section.upper()}_" for setting in keys.values()}
117
+ layer: Layer = {}
118
+ for name, raw in sorted(environ.items()):
119
+ setting = names.get(name)
120
+ if setting is not None:
121
+ value: JsonValue = parse_value(raw)
122
+ if setting.is_list and not raw.strip():
123
+ value = []
124
+ elif setting.is_list and not isinstance(value, list):
125
+ value = [raw if setting.textual else value]
126
+ elif not setting.is_list and setting.textual:
127
+ value = raw
128
+ layer.setdefault(setting.section, {})[setting.field] = value
129
+ elif any(name.startswith(prefix) for prefix in prefixes):
130
+ raise PreconditionError(
131
+ f"Unknown setting {name}",
132
+ f"Unset {name}; setting variables are OFFBOX_<SECTION>_<KEY> for keys in the configuration reference.",
133
+ )
134
+ _validate(layer, "OFFBOX_* environment variables")
135
+ return layer
136
+
137
+
138
+ def user_layer(paths: Paths) -> Layer:
139
+ project = load_config(paths.user_config, UserConfig).project
140
+ _validate(project, f"{paths.user_config} [project]")
141
+ return project
142
+
143
+
144
+ def resolve(paths: Paths, root: Path, environ: Mapping[str, str] | None = None) -> ResolvedProject:
145
+ """Merge defaults, user [project] defaults, offbox.toml, offbox.local.toml and OFFBOX_* variables; later sources win per key."""
146
+ layers: list[tuple[Source, Layer]] = [
147
+ ("user", user_layer(paths)),
148
+ ("project", read_layer(root / PROJECT_FILE)),
149
+ ("local", read_layer(root / LOCAL_FILE)),
150
+ ("env", env_layer(os.environ if environ is None else environ)),
151
+ ]
152
+ merged: Layer = {}
153
+ sources: dict[str, Source] = dict.fromkeys(setting_keys(), "default")
154
+ for source, layer in layers:
155
+ for section, values in layer.items():
156
+ for key, value in values.items():
157
+ current = merged.setdefault(section, {})
158
+ for paired in _EXCLUSIVE.get(f"{section}.{key}", ()):
159
+ current.pop(paired.split(".", 1)[1], None)
160
+ sources[paired] = "default"
161
+ current[key] = value
162
+ sources[f"{section}.{key}"] = source
163
+ try:
164
+ config = ProjectConfig.model_validate(merged)
165
+ except ValidationError as exc:
166
+ details = "; ".join(error["msg"] for error in exc.errors(include_input=False))
167
+ raise PreconditionError(
168
+ f"Merged configuration is invalid: {details}",
169
+ "Correct the named settings in config.toml, offbox.toml, offbox.local.toml or OFFBOX_* variables.",
170
+ ) from exc
171
+ return ResolvedProject(config=config, sources=sources)
172
+
173
+
174
+ def load_project(paths: Paths, root: Path, environ: Mapping[str, str] | None = None) -> ProjectConfig:
175
+ return resolve(paths, root, environ).config
176
+
177
+
178
+ def report(paths: Paths, root: Path, environ: Mapping[str, str] | None = None) -> ConfigReport:
179
+ resolved = resolve(paths, root, environ)
180
+ dumped = resolved.config.model_dump(mode="json", by_alias=True)
181
+ settings = [
182
+ Setting(
183
+ key=key, value=_JSON.validate_python(dumped[setting.section][setting.field]), source=resolved.sources[key], env=env_name(key)
184
+ )
185
+ for key, setting in setting_keys().items()
186
+ ]
187
+ return ConfigReport(files=ConfigFiles(user=paths.user_config, project=root / PROJECT_FILE, local=root / LOCAL_FILE), settings=settings)
offbox/providers.py ADDED
@@ -0,0 +1,117 @@
1
+ import hashlib
2
+ import os
3
+ import re
4
+ import shutil
5
+ import tomllib
6
+ from collections.abc import Mapping
7
+ from importlib.resources import files
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from pydantic import Field, ValidationError
12
+
13
+ from offbox.config import Model, hetzner_token
14
+ from offbox.errors import PreconditionError
15
+ from offbox.logging import register_secrets
16
+ from offbox.paths import Paths
17
+
18
+
19
+ class ProviderDefaults(Model):
20
+ size: str | None = None
21
+ region: str | None = None
22
+ image: str | None = None
23
+
24
+
25
+ class ProviderManifest(Model):
26
+ name: str
27
+ description: str
28
+ driver: Literal["tofu", "none"]
29
+ credentials_env: list[str] = Field(default_factory=list[str])
30
+ defaults: ProviderDefaults = Field(default_factory=ProviderDefaults)
31
+
32
+
33
+ class Provider(Model):
34
+ manifest: ProviderManifest
35
+ directory: Path
36
+
37
+
38
+ def validate_name(value: str) -> str:
39
+ if re.fullmatch(r"[a-z][a-z0-9-]{0,31}", value) is None:
40
+ raise PreconditionError(f"Invalid name: {value!r}", "Use 1–32 lowercase letters, digits or hyphens, starting with a letter.")
41
+ return value
42
+
43
+
44
+ def load_provider(directory: Path, name: str | None = None) -> Provider:
45
+ path = directory / "provider.toml"
46
+ try:
47
+ with path.open("rb") as source:
48
+ manifest = ProviderManifest.model_validate(tomllib.load(source))
49
+ validate_name(manifest.name)
50
+ if name is not None and manifest.name != name:
51
+ raise PreconditionError(f"Provider name does not match {name}: {path}", "Correct the provider manifest name.")
52
+ for variable in manifest.credentials_env:
53
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", variable) is None:
54
+ raise PreconditionError(f"Invalid credential variable in {path}", "Use valid environment variable names.")
55
+ return Provider(manifest=manifest, directory=directory)
56
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError, ValidationError) as exc:
57
+ raise PreconditionError(f"Cannot load provider {path}", "Check the provider manifest syntax, fields and permissions.") from exc
58
+
59
+
60
+ def discover_provider(name: str, paths: Paths) -> Provider:
61
+ validate_name(name)
62
+ custom = paths.config / "providers" / name
63
+ if custom.exists():
64
+ return load_provider(custom, name)
65
+ resource = files("offbox").joinpath("_data", "providers", name)
66
+ if not resource.is_dir():
67
+ raise PreconditionError(f"Unknown provider: {name}", f"Install a provider in {custom}.")
68
+ return load_provider(Path(str(resource)), name)
69
+
70
+
71
+ TERRAFORM_LOCK_FILE = ".terraform.lock.hcl"
72
+
73
+
74
+ def is_provider_file(relative: Path) -> bool:
75
+ """Whether a path inside a provider folder belongs in a host workspace; local tool state and tests do not."""
76
+ for part in relative.parts:
77
+ if part.startswith(".terraform") and part != TERRAFORM_LOCK_FILE:
78
+ return False
79
+ if ".tfstate" in part or part.endswith((".tfplan", ".swp", "~")) or part in {".gitkeep", ".DS_Store", "offbox.auto.tfvars.json"}:
80
+ return False
81
+ return relative.parts[0] != "tests"
82
+
83
+
84
+ def copy_provider(provider: Provider, destination: Path) -> str:
85
+ sources = sorted(path for path in provider.directory.rglob("*") if is_provider_file(path.relative_to(provider.directory)))
86
+ if any(path.is_symlink() for path in sources):
87
+ raise PreconditionError("Provider contains symbolic links", "Replace provider symlinks with regular files before creating a host.")
88
+ try:
89
+ destination.mkdir(mode=0o700, parents=True)
90
+ for source in sources:
91
+ target = destination / source.relative_to(provider.directory)
92
+ if source.is_dir():
93
+ target.mkdir(mode=0o700, exist_ok=True)
94
+ elif source.is_file():
95
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
96
+ shutil.copy2(source, target)
97
+ digest = hashlib.sha256()
98
+ for path in sorted(destination.rglob("*")):
99
+ if path.is_file():
100
+ digest.update(path.relative_to(destination).as_posix().encode() + b"\0" + path.read_bytes() + b"\0")
101
+ return digest.hexdigest()
102
+ except OSError as exc:
103
+ raise PreconditionError(f"Cannot copy provider to {destination}", "Check free space and state directory permissions.") from exc
104
+
105
+
106
+ def credentials_env(manifest: ProviderManifest, paths: Paths, env: Mapping[str, str] | None = None) -> dict[str, str]:
107
+ values = os.environ if env is None else env
108
+ result: dict[str, str] = {}
109
+ for name in manifest.credentials_env:
110
+ if name == "HCLOUD_TOKEN":
111
+ result[name] = hetzner_token(paths.credentials, values).get_secret_value()
112
+ elif values.get(name):
113
+ result[name] = values[name]
114
+ else:
115
+ raise PreconditionError(f"Missing provider credential: {name}", f"Set {name} before retrying.")
116
+ register_secrets(result)
117
+ return result
offbox/py.typed ADDED
File without changes
offbox/runner.py ADDED
@@ -0,0 +1,291 @@
1
+ import logging
2
+ import os
3
+ import selectors
4
+ import shlex
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ from collections.abc import Mapping, Sequence
9
+ from contextlib import suppress
10
+ from pathlib import Path
11
+ from typing import IO, Protocol
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from offbox import console
16
+ from offbox.errors import CommandFailed, PreconditionError, ProviderInterrupted
17
+ from offbox.logging import redact, register_secrets
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Result(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+ argv: list[str]
25
+ returncode: int
26
+ stdout: str = ""
27
+ stderr: str = ""
28
+
29
+
30
+ class StreamProcess(Protocol):
31
+ def poll(self) -> int | None: ...
32
+ def wait(self, timeout: float | None = None) -> int | None:
33
+ """Return None if a bounded wait expires while the process is still running."""
34
+ ...
35
+
36
+ def kill(self) -> None: ...
37
+
38
+
39
+ class _SubprocessStream:
40
+ def __init__(self, process: subprocess.Popen[bytes], *, grouped: bool, started: float, keepalive: IO[bytes] | None = None) -> None:
41
+ self.process = process
42
+ self.grouped = grouped
43
+ self.started = started
44
+ self.keepalive = keepalive
45
+ self.finished = False
46
+
47
+ def _finish(self, code: int | None) -> int | None:
48
+ if code is not None and not self.finished:
49
+ logger.debug("stream exit=%s duration=%.3f", code, time.monotonic() - self.started)
50
+ self.finished = True
51
+ if self.keepalive is not None:
52
+ with suppress(OSError):
53
+ self.keepalive.close()
54
+ return code
55
+
56
+ def poll(self) -> int | None:
57
+ try:
58
+ return self._finish(self.process.poll())
59
+ except OSError as exc:
60
+ raise PreconditionError("Cannot inspect streaming command", "Check the local process and retry.") from exc
61
+
62
+ def wait(self, timeout: float | None = None) -> int | None:
63
+ try:
64
+ return self._finish(self.process.wait(timeout=timeout))
65
+ except subprocess.TimeoutExpired:
66
+ return None
67
+ except OSError as exc:
68
+ raise PreconditionError("Cannot wait for streaming command", "Check the local process and retry.") from exc
69
+
70
+ def kill(self) -> None:
71
+ if self.poll() is not None:
72
+ return
73
+ try:
74
+ with suppress(ProcessLookupError):
75
+ if self.grouped:
76
+ os.killpg(self.process.pid, signal.SIGKILL)
77
+ else:
78
+ self.process.kill()
79
+ except OSError as exc:
80
+ raise PreconditionError("Cannot stop streaming command", "Stop the local SSH process and inspect the remote command.") from exc
81
+ if self.wait(timeout=1.0) is None:
82
+ raise PreconditionError("Cannot reap streaming command within 1s", "Inspect and stop the local SSH process manually.")
83
+
84
+
85
+ class Runner(Protocol):
86
+ def run(
87
+ self,
88
+ argv: Sequence[str],
89
+ *,
90
+ check: bool = True,
91
+ capture: bool = True,
92
+ input: str | None = None,
93
+ env: Mapping[str, str] | None = None,
94
+ cwd: Path | None = None,
95
+ timeout: float | None = None,
96
+ interruptible: bool = False,
97
+ ) -> Result: ...
98
+
99
+ def stream(self, argv: Sequence[str], *, env: Mapping[str, str] | None = None, stdin: bool = False, tty: bool = False) -> int: ...
100
+
101
+ def start_stream(
102
+ self,
103
+ argv: Sequence[str],
104
+ *,
105
+ env: Mapping[str, str] | None = None,
106
+ stdin: bool = False,
107
+ tty: bool = False,
108
+ keepalive: bool = False,
109
+ ) -> StreamProcess:
110
+ """keepalive gives the child a stdin pipe that stays open until this process ends, so the far side sees a client exit."""
111
+ ...
112
+
113
+
114
+ class SubprocessRunner:
115
+ def __init__(self, verbose: bool = False) -> None:
116
+ self.verbose = verbose
117
+
118
+ def _start(self, argv: Sequence[str], env: Mapping[str, str] | None) -> tuple[float, dict[str, str]]:
119
+ values = dict(os.environ)
120
+ if env is not None:
121
+ values.update(env)
122
+ if not argv:
123
+ raise PreconditionError("Empty command", "Supply a command to run.")
124
+ register_secrets(values)
125
+ display = shlex.join([redact(arg, values) for arg in argv])
126
+ logger.debug("command %s", display)
127
+ if self.verbose:
128
+ console.command(display)
129
+ return time.monotonic(), values
130
+
131
+ def _log_output(self, name: str, content: bytes, env: Mapping[str, str]) -> None:
132
+ text = redact(content.decode(errors="replace").rstrip("\r\n"), env)
133
+ logger.info("%s: %s", name, text)
134
+ if self.verbose:
135
+ console.info(text)
136
+
137
+ def _run_logged(
138
+ self, argv: Sequence[str], env: Mapping[str, str], cwd: Path | None, timeout: float | None
139
+ ) -> subprocess.CompletedProcess[str]:
140
+ deadline = None if timeout is None else time.monotonic() + timeout
141
+
142
+ def remaining() -> float | None:
143
+ if deadline is None:
144
+ return None
145
+ duration = deadline - time.monotonic()
146
+ if duration <= 0:
147
+ raise subprocess.TimeoutExpired(list(argv), timeout or 0)
148
+ return duration
149
+
150
+ with subprocess.Popen(
151
+ list(argv), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, cwd=cwd, start_new_session=True
152
+ ) as process:
153
+ if process.stdout is None or process.stderr is None:
154
+ raise PreconditionError("Cannot capture provider output", "Check subprocess pipe support and retry.")
155
+ stdout, stderr = process.stdout.fileno(), process.stderr.fileno()
156
+ outputs = {stdout: bytearray(), stderr: bytearray()}
157
+ pending = {stdout: bytearray(), stderr: bytearray()}
158
+ names = {stdout: "stdout", stderr: "stderr"}
159
+ interrupts = 0
160
+
161
+ def interrupt() -> None:
162
+ nonlocal interrupts
163
+ interrupts += 1
164
+ if interrupts == 1:
165
+ with suppress(ProcessLookupError):
166
+ os.killpg(process.pid, signal.SIGINT)
167
+ console.warn("Interrupt received; waiting for the provider to save state. Do not close this terminal.")
168
+ elif interrupts == 2:
169
+ console.warn(
170
+ "The provider is still finishing and recording created resources. "
171
+ "Press Ctrl-C again to force-stop it; untracked cloud resources may keep billing."
172
+ )
173
+ else:
174
+ with suppress(ProcessLookupError):
175
+ os.killpg(process.pid, signal.SIGKILL)
176
+ process.wait()
177
+ raise ProviderInterrupted(
178
+ "Provider operation was force-stopped before saving its state",
179
+ "Check the provider console for resources labelled offbox-host and delete them, then run offbox host destroy NAME.",
180
+ killed=True,
181
+ )
182
+
183
+ try:
184
+ with selectors.DefaultSelector() as selector:
185
+ selector.register(process.stdout, selectors.EVENT_READ)
186
+ selector.register(process.stderr, selectors.EVENT_READ)
187
+ while selector.get_map():
188
+ try:
189
+ for key, _ in selector.select(remaining()):
190
+ chunk = os.read(key.fd, 65536)
191
+ if not chunk:
192
+ selector.unregister(key.fd)
193
+ if pending[key.fd]:
194
+ self._log_output(names[key.fd], bytes(pending[key.fd]), env)
195
+ continue
196
+ outputs[key.fd].extend(chunk)
197
+ pending[key.fd].extend(chunk)
198
+ while b"\n" in pending[key.fd]:
199
+ end = pending[key.fd].index(b"\n") + 1
200
+ self._log_output(names[key.fd], bytes(pending[key.fd][:end]), env)
201
+ del pending[key.fd][:end]
202
+ except KeyboardInterrupt:
203
+ interrupt()
204
+ while True:
205
+ try:
206
+ returncode = process.wait(timeout=remaining())
207
+ break
208
+ except KeyboardInterrupt:
209
+ interrupt()
210
+ if interrupts:
211
+ raise ProviderInterrupted(
212
+ "Provider operation interrupted", "Run offbox host list and destroy the interrupted host.", killed=False
213
+ )
214
+ except (OSError, subprocess.TimeoutExpired, KeyboardInterrupt):
215
+ with suppress(ProcessLookupError):
216
+ os.killpg(process.pid, signal.SIGKILL)
217
+ process.wait()
218
+ raise
219
+ return subprocess.CompletedProcess(
220
+ list(argv), returncode, bytes(outputs[stdout]).decode(errors="replace"), bytes(outputs[stderr]).decode(errors="replace")
221
+ )
222
+
223
+ def run(
224
+ self,
225
+ argv: Sequence[str],
226
+ *,
227
+ check: bool = True,
228
+ capture: bool = True,
229
+ input: str | None = None,
230
+ env: Mapping[str, str] | None = None,
231
+ cwd: Path | None = None,
232
+ timeout: float | None = None,
233
+ interruptible: bool = False,
234
+ ) -> Result:
235
+ start, values = self._start(argv, env)
236
+ try:
237
+ if interruptible:
238
+ completed = self._run_logged(argv, values, cwd, timeout)
239
+ else:
240
+ completed = subprocess.run(
241
+ list(argv),
242
+ capture_output=capture,
243
+ input=input,
244
+ stdin=subprocess.DEVNULL if input is None else None,
245
+ env=values,
246
+ cwd=cwd,
247
+ timeout=timeout,
248
+ text=True,
249
+ check=False,
250
+ )
251
+ except subprocess.TimeoutExpired as exc:
252
+ logger.error("command timeout duration=%.3f", time.monotonic() - start)
253
+ raise PreconditionError(
254
+ f"Command timed out: {shlex.join([redact(arg, values) for arg in argv])}", "Check connectivity and try again."
255
+ ) from exc
256
+ except OSError as exc:
257
+ logger.error("command launch failed duration=%.3f", time.monotonic() - start)
258
+ raise PreconditionError(f"Cannot run {argv[0]}", f"Install {argv[0]} and check its permissions.") from exc
259
+ result = Result(argv=list(argv), returncode=completed.returncode, stdout=completed.stdout or "", stderr=completed.stderr or "")
260
+ logger.debug("command exit=%s duration=%.3f", result.returncode, time.monotonic() - start)
261
+ if check and result.returncode:
262
+ raise CommandFailed([redact(arg, values) for arg in argv], result.returncode, redact(result.stderr, values))
263
+ return result
264
+
265
+ def stream(self, argv: Sequence[str], *, env: Mapping[str, str] | None = None, stdin: bool = False, tty: bool = False) -> int:
266
+ start, values = self._start(argv, env)
267
+ try:
268
+ result = subprocess.run(list(argv), env=values, stdin=None if stdin or tty else subprocess.DEVNULL, check=False)
269
+ except OSError as exc:
270
+ logger.error("stream launch failed duration=%.3f", time.monotonic() - start)
271
+ raise PreconditionError(f"Cannot run {argv[0]}", f"Install {argv[0]} and check its permissions.") from exc
272
+ logger.debug("stream exit=%s duration=%.3f", result.returncode, time.monotonic() - start)
273
+ return result.returncode
274
+
275
+ def start_stream(
276
+ self,
277
+ argv: Sequence[str],
278
+ *,
279
+ env: Mapping[str, str] | None = None,
280
+ stdin: bool = False,
281
+ tty: bool = False,
282
+ keepalive: bool = False,
283
+ ) -> StreamProcess:
284
+ started, values = self._start(argv, env)
285
+ source = subprocess.PIPE if keepalive and not (stdin or tty) else None if stdin or tty else subprocess.DEVNULL
286
+ try:
287
+ process = subprocess.Popen(list(argv), env=values, stdin=source, start_new_session=not tty)
288
+ except OSError as exc:
289
+ logger.error("stream launch failed duration=%.3f", time.monotonic() - started)
290
+ raise PreconditionError(f"Cannot run {argv[0]}", f"Install {argv[0]} and check its permissions.") from exc
291
+ return _SubprocessStream(process, grouped=not tty, started=started, keepalive=process.stdin if source == subprocess.PIPE else None)