remote-exec-grpc 0.2.1__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.
- remote_exec/__init__.py +10 -0
- remote_exec/claude_skill.md +118 -0
- remote_exec/client.py +242 -0
- remote_exec/client_cli.py +226 -0
- remote_exec/cmdlog.py +159 -0
- remote_exec/config.py +202 -0
- remote_exec/executor.py +287 -0
- remote_exec/policy.py +420 -0
- remote_exec/security.py +178 -0
- remote_exec/server.py +292 -0
- remote_exec/server_cli.py +219 -0
- remote_exec/skill.py +39 -0
- remote_exec/v1/__init__.py +1 -0
- remote_exec/v1/remote_exec_pb2.py +57 -0
- remote_exec/v1/remote_exec_pb2.pyi +108 -0
- remote_exec/v1/remote_exec_pb2_grpc.py +142 -0
- remote_exec_grpc-0.2.1.dist-info/METADATA +539 -0
- remote_exec_grpc-0.2.1.dist-info/RECORD +20 -0
- remote_exec_grpc-0.2.1.dist-info/WHEEL +4 -0
- remote_exec_grpc-0.2.1.dist-info/entry_points.txt +3 -0
remote_exec/cmdlog.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Command log: every executed command together with its output.
|
|
2
|
+
|
|
3
|
+
One text file shared by all executions (each line carries the execution id,
|
|
4
|
+
so concurrent commands can be told apart)::
|
|
5
|
+
|
|
6
|
+
2026-10-04 10:00:00.120 [3f2a9c1d0b7e] START client=ipv4:10.0.0.7:51234 cwd="/proj" timeout=0 env_keys="" command="make"
|
|
7
|
+
2026-10-04 10:00:00.250 [3f2a9c1d0b7e] OUT| gcc -c main.c
|
|
8
|
+
2026-10-04 10:00:00.260 [3f2a9c1d0b7e] ERR| warning: unused variable
|
|
9
|
+
2026-10-04 10:00:01.900 [3f2a9c1d0b7e] END status=exited exit_code=0 duration=1.780s
|
|
10
|
+
|
|
11
|
+
Output is split into lines per stream (undecodable bytes are replaced).
|
|
12
|
+
When the file would exceed ``max_bytes`` it is renamed to
|
|
13
|
+
``<name>.<timestamp><suffix>`` and a new file is started; ``keep`` limits how
|
|
14
|
+
many rotated files are retained (0 = keep all). Files are created with mode
|
|
15
|
+
600 because command output may contain sensitive data. Environment variable
|
|
16
|
+
values are never written.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
DEFAULT_COMMAND_LOG = "remote-exec-commands.log"
|
|
30
|
+
DEFAULT_MAX_MB = 10.0
|
|
31
|
+
MAX_LINE_BYTES = 64 * 1024 # longer output without newline is written in pieces
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _q(value: str) -> str:
|
|
35
|
+
return json.dumps(value, ensure_ascii=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CommandLog:
|
|
39
|
+
def __init__(self, path: str | os.PathLike, max_bytes: int = int(DEFAULT_MAX_MB * 1024 * 1024), keep: int = 0):
|
|
40
|
+
if max_bytes < 1024:
|
|
41
|
+
raise ValueError("command log size limit must be at least 1 KiB")
|
|
42
|
+
if keep < 0:
|
|
43
|
+
raise ValueError("number of rotated command logs to keep must be >= 0")
|
|
44
|
+
self.path = Path(path).expanduser().absolute()
|
|
45
|
+
self.max_bytes = max_bytes
|
|
46
|
+
self.keep = keep
|
|
47
|
+
self._pending: dict[tuple[str, str], bytearray] = {}
|
|
48
|
+
self._error_reported = False
|
|
49
|
+
self._file = None
|
|
50
|
+
self._open()
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------ public API
|
|
53
|
+
|
|
54
|
+
def start(self, execution_id: str, client: str, cwd: str, command: str, timeout: float, env_keys) -> None:
|
|
55
|
+
self._write(self._line(
|
|
56
|
+
execution_id,
|
|
57
|
+
f"START client={client} cwd={_q(cwd)} timeout={timeout:g} "
|
|
58
|
+
f"env_keys={_q(','.join(sorted(env_keys)))} command={_q(command)}",
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
def output(self, execution_id: str, stream: str, data: bytes) -> None:
|
|
62
|
+
"""Record a chunk of ``stream`` ("stdout"/"stderr"); complete lines are written."""
|
|
63
|
+
key = (execution_id, stream)
|
|
64
|
+
buf = self._pending.setdefault(key, bytearray())
|
|
65
|
+
buf.extend(data)
|
|
66
|
+
lines = []
|
|
67
|
+
while (nl := buf.find(b"\n")) >= 0:
|
|
68
|
+
lines.append(bytes(buf[:nl]))
|
|
69
|
+
del buf[:nl + 1]
|
|
70
|
+
while len(buf) > MAX_LINE_BYTES:
|
|
71
|
+
lines.append(bytes(buf[:MAX_LINE_BYTES]))
|
|
72
|
+
del buf[:MAX_LINE_BYTES]
|
|
73
|
+
if lines:
|
|
74
|
+
self._write("".join(self._output_line(execution_id, stream, line) for line in lines))
|
|
75
|
+
|
|
76
|
+
def end(self, execution_id: str, status: str, exit_code, duration: float) -> None:
|
|
77
|
+
text = []
|
|
78
|
+
for stream in ("stdout", "stderr"):
|
|
79
|
+
rest = self._pending.pop((execution_id, stream), None)
|
|
80
|
+
if rest:
|
|
81
|
+
text.append(self._output_line(execution_id, stream, bytes(rest)))
|
|
82
|
+
text.append(self._line(execution_id, f"END status={status} exit_code={exit_code} duration={duration:.3f}s"))
|
|
83
|
+
self._write("".join(text))
|
|
84
|
+
|
|
85
|
+
def rejected(self, client: str, command: str, reason: str) -> None:
|
|
86
|
+
self._write(self._line("-" * 12, f"REJECTED client={client} reason={_q(reason)} command={_q(command)}"))
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
if self._file:
|
|
90
|
+
self._file.close()
|
|
91
|
+
self._file = None
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------ internals
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _line(execution_id: str, message: str) -> str:
|
|
97
|
+
ts = datetime.now().isoformat(sep=" ", timespec="milliseconds")
|
|
98
|
+
return f"{ts} [{execution_id}] {message}\n"
|
|
99
|
+
|
|
100
|
+
def _output_line(self, execution_id: str, stream: str, raw: bytes) -> str:
|
|
101
|
+
text = raw.decode("utf-8", errors="replace").rstrip("\r")
|
|
102
|
+
return self._line(execution_id, f"{'OUT' if stream == 'stdout' else 'ERR'}| {text}")
|
|
103
|
+
|
|
104
|
+
def _open(self) -> None:
|
|
105
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
107
|
+
self._file = os.fdopen(fd, "a", encoding="utf-8")
|
|
108
|
+
self._size = os.fstat(fd).st_size
|
|
109
|
+
|
|
110
|
+
def _write(self, text: str) -> None:
|
|
111
|
+
if self._file is None:
|
|
112
|
+
return
|
|
113
|
+
data_len = len(text.encode("utf-8"))
|
|
114
|
+
try:
|
|
115
|
+
if self._size > 0 and self._size + data_len > self.max_bytes:
|
|
116
|
+
self._rotate()
|
|
117
|
+
self._file.write(text)
|
|
118
|
+
self._file.flush()
|
|
119
|
+
self._size += data_len
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
# Never let logging problems (e.g. disk full) break command execution.
|
|
122
|
+
if not self._error_reported:
|
|
123
|
+
log.error("cannot write command log %s: %s (further errors suppressed)", self.path, exc)
|
|
124
|
+
self._error_reported = True
|
|
125
|
+
|
|
126
|
+
def _rotated_name(self) -> Path:
|
|
127
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
128
|
+
candidate = self.path.with_name(f"{self.path.stem}.{stamp}{self.path.suffix}")
|
|
129
|
+
n = 1
|
|
130
|
+
while candidate.exists():
|
|
131
|
+
candidate = self.path.with_name(f"{self.path.stem}.{stamp}-{n}{self.path.suffix}")
|
|
132
|
+
n += 1
|
|
133
|
+
return candidate
|
|
134
|
+
|
|
135
|
+
def rotated_files(self) -> list[Path]:
|
|
136
|
+
"""Rotated files, oldest first (by last write time, then name)."""
|
|
137
|
+
pattern = f"{self.path.stem}.*{self.path.suffix}"
|
|
138
|
+
files = []
|
|
139
|
+
for p in self.path.parent.glob(pattern):
|
|
140
|
+
if p != self.path:
|
|
141
|
+
try:
|
|
142
|
+
files.append((p.stat().st_mtime, p.name, p))
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return [p for _, _, p in sorted(files)]
|
|
146
|
+
|
|
147
|
+
def _rotate(self) -> None:
|
|
148
|
+
self._file.close()
|
|
149
|
+
self._file = None
|
|
150
|
+
try:
|
|
151
|
+
os.replace(self.path, self._rotated_name())
|
|
152
|
+
if self.keep:
|
|
153
|
+
for old in self.rotated_files()[:-self.keep]:
|
|
154
|
+
try:
|
|
155
|
+
old.unlink()
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
finally:
|
|
159
|
+
self._open() # if the rename failed, keep appending to the current file
|
remote_exec/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Configuration loading.
|
|
2
|
+
|
|
3
|
+
Client config (YAML), default location ``$REMOTE_EXEC_CONFIG`` or
|
|
4
|
+
``${XDG_CONFIG_HOME:-~/.config}/remote-exec/config.yaml``::
|
|
5
|
+
|
|
6
|
+
defaults: # optional, merged into every server entry
|
|
7
|
+
token_file: ~/.config/remote-exec/token
|
|
8
|
+
servers:
|
|
9
|
+
fpga01:
|
|
10
|
+
host: fpga01.example.local
|
|
11
|
+
port: 50052 # optional, defaults to 50051
|
|
12
|
+
description: FPGA builds (Vivado, Questa)
|
|
13
|
+
|
|
14
|
+
Server config (YAML, only with ``remote-exec-server --config``) uses the long
|
|
15
|
+
option names of the server CLI as keys, e.g. ``host``, ``port``, ``token_file``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field, fields
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
DEFAULT_PORT = 50051
|
|
28
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
29
|
+
CONFIG_ENV_VAR = "REMOTE_EXEC_CONFIG"
|
|
30
|
+
TOKEN_ENV_VAR = "REMOTE_EXEC_TOKEN"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_client_config_path() -> Path:
|
|
38
|
+
if env := os.environ.get(CONFIG_ENV_VAR):
|
|
39
|
+
return Path(env).expanduser()
|
|
40
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
41
|
+
return Path(base) / "remote-exec" / "config.yaml"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_port(value: Any, where: str = "port") -> int:
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
raise ConfigError(f"{where}: invalid port {value!r}")
|
|
47
|
+
try:
|
|
48
|
+
port = int(value)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
raise ConfigError(f"{where}: invalid port {value!r}") from None
|
|
51
|
+
if not 1 <= port <= 65535:
|
|
52
|
+
raise ConfigError(f"{where}: port {port} out of range 1-65535")
|
|
53
|
+
return port
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def split_host_port(value: str, default_port: int = DEFAULT_PORT) -> tuple[str, int]:
|
|
57
|
+
"""Parse ``host``, ``host:port``, ``[v6]`` or ``[v6]:port``."""
|
|
58
|
+
value = value.strip()
|
|
59
|
+
if value.startswith("["):
|
|
60
|
+
host, sep, rest = value[1:].partition("]")
|
|
61
|
+
if not sep:
|
|
62
|
+
raise ConfigError(f"invalid address {value!r}")
|
|
63
|
+
if not rest:
|
|
64
|
+
return host, default_port
|
|
65
|
+
if not rest.startswith(":"):
|
|
66
|
+
raise ConfigError(f"invalid address {value!r}")
|
|
67
|
+
return host, validate_port(rest[1:], value)
|
|
68
|
+
if value.count(":") == 1:
|
|
69
|
+
host, port = value.split(":")
|
|
70
|
+
return host, validate_port(port, value)
|
|
71
|
+
return value, default_port # plain host or bare IPv6
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_address(host: str, port: int) -> str:
|
|
75
|
+
return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_yaml(path: Path) -> dict:
|
|
79
|
+
try:
|
|
80
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise ConfigError(f"cannot read config {path}: {exc.strerror or exc}") from exc
|
|
83
|
+
except yaml.YAMLError as exc:
|
|
84
|
+
raise ConfigError(f"invalid YAML in {path}: {exc}") from exc
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ConfigError(f"{path}: top level must be a mapping")
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- client
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ServerEndpoint:
|
|
95
|
+
name: str
|
|
96
|
+
host: str
|
|
97
|
+
port: int = DEFAULT_PORT
|
|
98
|
+
description: str = ""
|
|
99
|
+
token: str | None = None
|
|
100
|
+
token_file: str | None = None
|
|
101
|
+
tls: bool = False
|
|
102
|
+
ca_cert: str | None = None
|
|
103
|
+
client_cert: str | None = None
|
|
104
|
+
client_key: str | None = None
|
|
105
|
+
tls_server_name: str | None = None
|
|
106
|
+
connect_timeout: float | None = None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def address(self) -> str:
|
|
110
|
+
return format_address(self.host, self.port)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def uses_tls(self) -> bool:
|
|
114
|
+
return self.tls or bool(self.ca_cert or self.client_cert)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_ENDPOINT_KEYS = {f.name for f in fields(ServerEndpoint)} - {"name"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _make_endpoint(name: str, raw: Any, defaults: dict, where: str) -> ServerEndpoint:
|
|
121
|
+
if not isinstance(raw, dict):
|
|
122
|
+
raise ConfigError(f"{where}: server entry must be a mapping")
|
|
123
|
+
if "token" in raw or "token_file" in raw:
|
|
124
|
+
# A server's own credential replaces the default one entirely; otherwise a
|
|
125
|
+
# default `token` would win over the server's `token_file`.
|
|
126
|
+
defaults = {k: v for k, v in defaults.items() if k not in ("token", "token_file")}
|
|
127
|
+
merged = {**defaults, **raw}
|
|
128
|
+
unknown = set(merged) - _ENDPOINT_KEYS
|
|
129
|
+
if unknown:
|
|
130
|
+
raise ConfigError(f"{where}: unknown key(s): {', '.join(sorted(unknown))}")
|
|
131
|
+
if not merged.get("host"):
|
|
132
|
+
raise ConfigError(f"{where}: 'host' is required")
|
|
133
|
+
merged["host"] = str(merged["host"])
|
|
134
|
+
merged["port"] = validate_port(merged.get("port", DEFAULT_PORT), f"{where}.port")
|
|
135
|
+
for key in ("token_file", "ca_cert", "client_cert", "client_key"):
|
|
136
|
+
if merged.get(key):
|
|
137
|
+
merged[key] = os.path.expanduser(str(merged[key]))
|
|
138
|
+
return ServerEndpoint(name=name, **merged)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ClientConfig:
|
|
143
|
+
servers: dict[str, ServerEndpoint] = field(default_factory=dict)
|
|
144
|
+
defaults: dict = field(default_factory=dict)
|
|
145
|
+
path: Path | None = None
|
|
146
|
+
|
|
147
|
+
def resolve(self, target: str) -> ServerEndpoint:
|
|
148
|
+
"""Look ``target`` up by name; otherwise treat it as ``host[:port]``."""
|
|
149
|
+
if target in self.servers:
|
|
150
|
+
return self.servers[target]
|
|
151
|
+
if not target:
|
|
152
|
+
raise ConfigError("empty server name")
|
|
153
|
+
host, port = split_host_port(target)
|
|
154
|
+
defaults = {k: v for k, v in self.defaults.items() if k != "port"}
|
|
155
|
+
return _make_endpoint(target, {**defaults, "host": host, "port": port}, {}, f"server {target!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_client_config(path: str | os.PathLike | None = None) -> ClientConfig:
|
|
159
|
+
"""Load the client config. A missing *default* config is not an error."""
|
|
160
|
+
explicit = path is not None
|
|
161
|
+
cfg_path = Path(path).expanduser() if explicit else default_client_config_path()
|
|
162
|
+
if not cfg_path.exists():
|
|
163
|
+
if explicit or os.environ.get(CONFIG_ENV_VAR):
|
|
164
|
+
raise ConfigError(f"config file not found: {cfg_path}")
|
|
165
|
+
return ClientConfig(path=None)
|
|
166
|
+
|
|
167
|
+
data = _load_yaml(cfg_path)
|
|
168
|
+
unknown = set(data) - {"servers", "defaults"}
|
|
169
|
+
if unknown:
|
|
170
|
+
raise ConfigError(f"{cfg_path}: unknown top-level key(s): {', '.join(sorted(unknown))}")
|
|
171
|
+
defaults = data.get("defaults") or {}
|
|
172
|
+
servers_raw = data.get("servers") or {}
|
|
173
|
+
if not isinstance(defaults, dict) or not isinstance(servers_raw, dict):
|
|
174
|
+
raise ConfigError(f"{cfg_path}: 'defaults' and 'servers' must be mappings")
|
|
175
|
+
servers = {
|
|
176
|
+
str(name): _make_endpoint(str(name), raw, defaults, f"{cfg_path}: servers.{name}")
|
|
177
|
+
for name, raw in servers_raw.items()
|
|
178
|
+
}
|
|
179
|
+
return ClientConfig(servers=servers, defaults=defaults, path=cfg_path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------- server
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
SERVER_CONFIG_KEYS = {
|
|
186
|
+
"host", "port", "listen", "token_file", "tls_cert", "tls_key", "tls_client_ca",
|
|
187
|
+
"max_concurrent", "max_timeout", "shell", "kill_grace", "log_level", "log_file",
|
|
188
|
+
"insecure_no_auth", "allow_root", "tools",
|
|
189
|
+
"command_log", "command_log_max_mb", "command_log_keep", "no_command_log",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_server_config(path: str | os.PathLike) -> dict[str, Any]:
|
|
194
|
+
cfg_path = Path(path).expanduser()
|
|
195
|
+
data = _load_yaml(cfg_path)
|
|
196
|
+
data = {k.replace("-", "_"): v for k, v in data.items()}
|
|
197
|
+
unknown = set(data) - SERVER_CONFIG_KEYS
|
|
198
|
+
if unknown:
|
|
199
|
+
raise ConfigError(f"{cfg_path}: unknown key(s): {', '.join(sorted(unknown))}")
|
|
200
|
+
if "port" in data:
|
|
201
|
+
data["port"] = validate_port(data["port"], f"{cfg_path}: port")
|
|
202
|
+
return data
|
remote_exec/executor.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Subprocess execution with streaming output and process-group cleanup.
|
|
2
|
+
|
|
3
|
+
This module knows nothing about gRPC. It turns a :class:`CommandSpec` into an
|
|
4
|
+
async stream of :class:`ExecEvent` objects, which keeps it reusable for future
|
|
5
|
+
APIs (e.g. Submit/GetStatus/GetResult backed by a task registry).
|
|
6
|
+
|
|
7
|
+
Process model
|
|
8
|
+
-------------
|
|
9
|
+
Every command runs as ``<shell> -c <command>`` in a *new session*
|
|
10
|
+
(``start_new_session=True``), so the shell and everything it spawns share a
|
|
11
|
+
process group whose id equals the shell's pid. Cleanup always targets the whole
|
|
12
|
+
group (SIGTERM, then SIGKILL after a grace period) so that tools like Vivado do
|
|
13
|
+
not leave orphaned children behind. This happens when:
|
|
14
|
+
|
|
15
|
+
* the command times out,
|
|
16
|
+
* the consumer stops iterating (client disconnect / cancellation),
|
|
17
|
+
* the main process exits but left background processes in its group.
|
|
18
|
+
|
|
19
|
+
A process that deliberately escapes the group (``setsid``/double fork) cannot be
|
|
20
|
+
tracked this way; under systemd the unit's cgroup still covers it on stop.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import enum
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import signal
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import AsyncIterator, Mapping
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
READ_CHUNK_SIZE = 64 * 1024
|
|
38
|
+
# Bounded so a slow consumer applies back-pressure all the way to the process
|
|
39
|
+
# (its pipe fills up and it blocks), instead of buffering unbounded output.
|
|
40
|
+
QUEUE_MAX_CHUNKS = 64
|
|
41
|
+
DEFAULT_KILL_GRACE = 5.0
|
|
42
|
+
# After the main process exited (and its group was cleaned up) wait at most
|
|
43
|
+
# this long for the pipes to reach EOF. Only matters if an escaped process
|
|
44
|
+
# still holds the pipes open.
|
|
45
|
+
PIPE_DRAIN_TIMEOUT = 5.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Stream(enum.Enum):
|
|
49
|
+
STDOUT = "stdout"
|
|
50
|
+
STDERR = "stderr"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Status(enum.Enum):
|
|
54
|
+
EXITED = "exited"
|
|
55
|
+
TIMEOUT = "timeout"
|
|
56
|
+
ERROR = "error"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CommandSpec:
|
|
61
|
+
command: str
|
|
62
|
+
cwd: str | None = None
|
|
63
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
64
|
+
timeout: float | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Started:
|
|
69
|
+
execution_id: str
|
|
70
|
+
pid: int
|
|
71
|
+
start_time: datetime
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Output:
|
|
76
|
+
stream: Stream
|
|
77
|
+
data: bytes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Finished:
|
|
82
|
+
execution_id: str
|
|
83
|
+
status: Status
|
|
84
|
+
exit_code: int
|
|
85
|
+
signal: int
|
|
86
|
+
start_time: datetime
|
|
87
|
+
end_time: datetime
|
|
88
|
+
message: str = ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
ExecEvent = Started | Output | Finished
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def utcnow() -> datetime:
|
|
95
|
+
return datetime.now(timezone.utc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _signal_process_group(pgid: int, sig: int) -> bool:
|
|
99
|
+
"""Send ``sig`` to a process group. Returns False if the group is gone."""
|
|
100
|
+
try:
|
|
101
|
+
os.killpg(pgid, sig)
|
|
102
|
+
return True
|
|
103
|
+
except ProcessLookupError:
|
|
104
|
+
return False
|
|
105
|
+
except PermissionError:
|
|
106
|
+
# Should not happen for our own children; treat as gone rather than crash.
|
|
107
|
+
log.warning("permission denied signalling process group %d", pgid)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def terminate_process_group(pgid: int, grace: float = DEFAULT_KILL_GRACE) -> None:
|
|
112
|
+
"""SIGTERM the group, wait up to ``grace`` seconds, then SIGKILL it."""
|
|
113
|
+
if not _signal_process_group(pgid, signal.SIGTERM):
|
|
114
|
+
return
|
|
115
|
+
loop = asyncio.get_running_loop()
|
|
116
|
+
deadline = loop.time() + grace
|
|
117
|
+
while loop.time() < deadline:
|
|
118
|
+
await asyncio.sleep(0.05)
|
|
119
|
+
if not _signal_process_group(pgid, 0):
|
|
120
|
+
return
|
|
121
|
+
log.info("process group %d survived SIGTERM for %.1fs, sending SIGKILL", pgid, grace)
|
|
122
|
+
_signal_process_group(pgid, signal.SIGKILL)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Executor:
|
|
126
|
+
"""Runs commands; tracks background cleanup tasks so shutdown can await them."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, shell: str = "/bin/sh", kill_grace: float = DEFAULT_KILL_GRACE):
|
|
129
|
+
self.shell = shell
|
|
130
|
+
self.kill_grace = kill_grace
|
|
131
|
+
self._cleanup_tasks: set[asyncio.Task] = set()
|
|
132
|
+
self._running_pgids: set[int] = set()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def running_count(self) -> int:
|
|
136
|
+
return len(self._running_pgids)
|
|
137
|
+
|
|
138
|
+
def build_env(self, extra: Mapping[str, str]) -> dict[str, str]:
|
|
139
|
+
env = dict(os.environ)
|
|
140
|
+
env.update(extra)
|
|
141
|
+
return env
|
|
142
|
+
|
|
143
|
+
async def run(self, spec: CommandSpec, execution_id: str | None = None) -> AsyncIterator[ExecEvent]:
|
|
144
|
+
"""Run ``spec`` and yield events. Always ends with exactly one Finished.
|
|
145
|
+
|
|
146
|
+
If the consumer stops early (``aclose()`` or task cancellation) the
|
|
147
|
+
process group is terminated in the background.
|
|
148
|
+
"""
|
|
149
|
+
execution_id = execution_id or uuid.uuid4().hex
|
|
150
|
+
loop = asyncio.get_running_loop()
|
|
151
|
+
start_time = utcnow()
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
proc = await asyncio.create_subprocess_exec(
|
|
155
|
+
self.shell,
|
|
156
|
+
"-c",
|
|
157
|
+
spec.command,
|
|
158
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
159
|
+
stdout=asyncio.subprocess.PIPE,
|
|
160
|
+
stderr=asyncio.subprocess.PIPE,
|
|
161
|
+
cwd=spec.cwd or None,
|
|
162
|
+
env=self.build_env(spec.env),
|
|
163
|
+
start_new_session=True,
|
|
164
|
+
)
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
where = f" (cwd={spec.cwd})" if spec.cwd else ""
|
|
167
|
+
yield Finished(
|
|
168
|
+
execution_id, Status.ERROR, -1, 0, start_time, utcnow(),
|
|
169
|
+
f"failed to start command{where}: {exc.strerror or exc}",
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
pgid = proc.pid # session leader => pgid == pid
|
|
174
|
+
self._running_pgids.add(pgid)
|
|
175
|
+
queue: asyncio.Queue[tuple[Stream, bytes | None]] = asyncio.Queue(QUEUE_MAX_CHUNKS)
|
|
176
|
+
|
|
177
|
+
async def pump(reader: asyncio.StreamReader, stream: Stream) -> None:
|
|
178
|
+
try:
|
|
179
|
+
while chunk := await reader.read(READ_CHUNK_SIZE):
|
|
180
|
+
await queue.put((stream, chunk))
|
|
181
|
+
finally:
|
|
182
|
+
# put_nowait could fail on a full queue; the consumer is gone
|
|
183
|
+
# in that case anyway (we are being cancelled).
|
|
184
|
+
try:
|
|
185
|
+
queue.put_nowait((stream, None))
|
|
186
|
+
except asyncio.QueueFull:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
async def wait_and_reap() -> int:
|
|
190
|
+
rc = await proc.wait()
|
|
191
|
+
# Kill whatever the command left behind in its process group.
|
|
192
|
+
await terminate_process_group(pgid, self.kill_grace)
|
|
193
|
+
return rc
|
|
194
|
+
|
|
195
|
+
timed_out = False
|
|
196
|
+
killer: asyncio.Task | None = None
|
|
197
|
+
|
|
198
|
+
def on_timeout() -> None:
|
|
199
|
+
nonlocal timed_out, killer
|
|
200
|
+
timed_out = True
|
|
201
|
+
log.info("execution %s timed out after %ss, terminating pgid %d", execution_id, spec.timeout, pgid)
|
|
202
|
+
killer = asyncio.ensure_future(terminate_process_group(pgid, self.kill_grace))
|
|
203
|
+
|
|
204
|
+
pumps = [
|
|
205
|
+
asyncio.ensure_future(pump(proc.stdout, Stream.STDOUT)),
|
|
206
|
+
asyncio.ensure_future(pump(proc.stderr, Stream.STDERR)),
|
|
207
|
+
]
|
|
208
|
+
waiter = asyncio.ensure_future(wait_and_reap())
|
|
209
|
+
timer = loop.call_later(spec.timeout, on_timeout) if spec.timeout else None
|
|
210
|
+
get_task: asyncio.Future | None = None
|
|
211
|
+
finished = False
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
yield Started(execution_id, proc.pid, start_time)
|
|
215
|
+
|
|
216
|
+
open_streams = 2
|
|
217
|
+
drain_deadline: float | None = None
|
|
218
|
+
while open_streams:
|
|
219
|
+
wait_timeout = None
|
|
220
|
+
if waiter.done():
|
|
221
|
+
if drain_deadline is None:
|
|
222
|
+
drain_deadline = loop.time() + PIPE_DRAIN_TIMEOUT
|
|
223
|
+
wait_timeout = drain_deadline - loop.time()
|
|
224
|
+
if wait_timeout <= 0:
|
|
225
|
+
log.warning("execution %s: pipes still open after process exit; giving up", execution_id)
|
|
226
|
+
break
|
|
227
|
+
if get_task is None:
|
|
228
|
+
get_task = asyncio.ensure_future(queue.get())
|
|
229
|
+
waitables = {get_task} if waiter.done() else {get_task, waiter}
|
|
230
|
+
done, _ = await asyncio.wait(waitables, timeout=wait_timeout, return_when=asyncio.FIRST_COMPLETED)
|
|
231
|
+
if get_task in done:
|
|
232
|
+
stream, data = get_task.result()
|
|
233
|
+
get_task = None
|
|
234
|
+
if data is None:
|
|
235
|
+
open_streams -= 1
|
|
236
|
+
else:
|
|
237
|
+
yield Output(stream, data)
|
|
238
|
+
|
|
239
|
+
returncode = await waiter
|
|
240
|
+
if timer:
|
|
241
|
+
timer.cancel()
|
|
242
|
+
if killer:
|
|
243
|
+
await killer
|
|
244
|
+
sig = -returncode if returncode < 0 else 0
|
|
245
|
+
exit_code = 128 + sig if sig else returncode
|
|
246
|
+
if timed_out:
|
|
247
|
+
status, message = Status.TIMEOUT, f"command timed out after {spec.timeout:g}s"
|
|
248
|
+
else:
|
|
249
|
+
status, message = Status.EXITED, ""
|
|
250
|
+
finished = True
|
|
251
|
+
yield Finished(execution_id, status, exit_code, sig, start_time, utcnow(), message)
|
|
252
|
+
finally:
|
|
253
|
+
if timer:
|
|
254
|
+
timer.cancel()
|
|
255
|
+
if get_task is not None:
|
|
256
|
+
get_task.cancel()
|
|
257
|
+
for task in pumps:
|
|
258
|
+
task.cancel()
|
|
259
|
+
if not finished:
|
|
260
|
+
# Consumer went away (client disconnect / cancel). Must not
|
|
261
|
+
# await here: we may be running inside a cancelled task.
|
|
262
|
+
log.info("execution %s aborted, terminating pgid %d", execution_id, pgid)
|
|
263
|
+
waiter.cancel()
|
|
264
|
+
_signal_process_group(pgid, signal.SIGTERM)
|
|
265
|
+
self._spawn_cleanup(proc, pgid)
|
|
266
|
+
self._running_pgids.discard(pgid)
|
|
267
|
+
|
|
268
|
+
def _spawn_cleanup(self, proc: asyncio.subprocess.Process, pgid: int) -> None:
|
|
269
|
+
async def cleanup() -> None:
|
|
270
|
+
await terminate_process_group(pgid, self.kill_grace)
|
|
271
|
+
try:
|
|
272
|
+
await asyncio.wait_for(proc.wait(), self.kill_grace)
|
|
273
|
+
except asyncio.TimeoutError:
|
|
274
|
+
log.warning("process %d did not exit after SIGKILL", proc.pid)
|
|
275
|
+
|
|
276
|
+
task = asyncio.ensure_future(cleanup())
|
|
277
|
+
self._cleanup_tasks.add(task)
|
|
278
|
+
task.add_done_callback(self._cleanup_tasks.discard)
|
|
279
|
+
|
|
280
|
+
def kill_all(self) -> None:
|
|
281
|
+
"""Synchronously SIGKILL all running process groups (last resort)."""
|
|
282
|
+
for pgid in list(self._running_pgids):
|
|
283
|
+
_signal_process_group(pgid, signal.SIGKILL)
|
|
284
|
+
|
|
285
|
+
async def wait_cleanups(self, timeout: float | None = None) -> None:
|
|
286
|
+
if self._cleanup_tasks:
|
|
287
|
+
await asyncio.wait(set(self._cleanup_tasks), timeout=timeout)
|