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