python-dmon 0.4.0__py3-none-any.whl → 0.5.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.
- dmon/api.py +32 -1
- dmon/cli.py +27 -1
- dmon/config.py +22 -1
- dmon/control.py +164 -16
- dmon/readiness.py +40 -5
- dmon/repair.py +303 -0
- dmon/results.py +22 -0
- dmon/supervisor.py +176 -15
- dmon/types.py +108 -5
- python_dmon-0.5.0.dist-info/METADATA +287 -0
- python_dmon-0.5.0.dist-info/RECORD +24 -0
- python_dmon-0.5.0.dist-info/licenses/LICENSE +21 -0
- python_dmon-0.4.0.dist-info/METADATA +0 -481
- python_dmon-0.4.0.dist-info/RECORD +0 -22
- {python_dmon-0.4.0.dist-info → python_dmon-0.5.0.dist-info}/WHEEL +0 -0
- {python_dmon-0.4.0.dist-info → python_dmon-0.5.0.dist-info}/entry_points.txt +0 -0
dmon/api.py
CHANGED
|
@@ -17,6 +17,7 @@ from .control import (
|
|
|
17
17
|
stop_single,
|
|
18
18
|
task_snapshot,
|
|
19
19
|
task_environment,
|
|
20
|
+
task_owns_listener,
|
|
20
21
|
)
|
|
21
22
|
from .results import (
|
|
22
23
|
ActionResult,
|
|
@@ -39,10 +40,19 @@ class DmonConfigError(DmonError):
|
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
class Dmon:
|
|
43
|
+
"""Silent, structured Python interface to dmon task operations.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: Optional dmon YAML/TOML file or directory. When omitted, dmon
|
|
47
|
+
uses the same configuration discovery rules as the CLI.
|
|
48
|
+
"""
|
|
49
|
+
|
|
42
50
|
def __init__(self, config: Optional[Union[str, os.PathLike]] = None) -> None:
|
|
43
51
|
self.config = str(config) if config is not None else None
|
|
44
52
|
|
|
45
53
|
def start(self, *tasks: str) -> BatchResult:
|
|
54
|
+
"""Start one or more configured tasks in the background."""
|
|
55
|
+
|
|
46
56
|
names, configs, _ = self._tasks(tasks)
|
|
47
57
|
self._validate_environments(configs)
|
|
48
58
|
results = []
|
|
@@ -59,12 +69,14 @@ class Dmon:
|
|
|
59
69
|
ok=result.exit_code == 0,
|
|
60
70
|
exit_code=result.exit_code,
|
|
61
71
|
snapshot=snapshot,
|
|
62
|
-
error=
|
|
72
|
+
error=result.error,
|
|
63
73
|
)
|
|
64
74
|
)
|
|
65
75
|
return BatchResult("start", tuple(results))
|
|
66
76
|
|
|
67
77
|
def stop(self, *tasks: str) -> BatchResult:
|
|
78
|
+
"""Stop one or more configured tasks using their recorded identities."""
|
|
79
|
+
|
|
68
80
|
names, configs, _ = self._tasks(tasks)
|
|
69
81
|
results = []
|
|
70
82
|
with self._operation():
|
|
@@ -82,6 +94,8 @@ class Dmon:
|
|
|
82
94
|
return BatchResult("stop", tuple(results))
|
|
83
95
|
|
|
84
96
|
def restart(self, *tasks: str) -> BatchResult:
|
|
97
|
+
"""Restart one or more configured tasks."""
|
|
98
|
+
|
|
85
99
|
names, configs, _ = self._tasks(tasks)
|
|
86
100
|
self._validate_environments(configs)
|
|
87
101
|
results = []
|
|
@@ -103,6 +117,8 @@ class Dmon:
|
|
|
103
117
|
return BatchResult("restart", tuple(results))
|
|
104
118
|
|
|
105
119
|
def status(self, task: Optional[str] = None) -> TaskResult:
|
|
120
|
+
"""Inspect one configured task without changing its lifecycle."""
|
|
121
|
+
|
|
106
122
|
names, configs, _ = self._tasks(() if task is None else (task,))
|
|
107
123
|
if len(names) != 1:
|
|
108
124
|
raise DmonConfigError("status requires exactly one task")
|
|
@@ -110,6 +126,8 @@ class Dmon:
|
|
|
110
126
|
return self._task_result(names[0], Path(configs[0].meta_path))
|
|
111
127
|
|
|
112
128
|
def list_tasks(self) -> Tuple[TaskResult, ...]:
|
|
129
|
+
"""Return recorded task results for the selected project."""
|
|
130
|
+
|
|
113
131
|
project = self._project()
|
|
114
132
|
results = []
|
|
115
133
|
with self._operation():
|
|
@@ -126,6 +144,8 @@ class Dmon:
|
|
|
126
144
|
return tuple(results)
|
|
127
145
|
|
|
128
146
|
def stack_status(self, stack: str) -> StackResult:
|
|
147
|
+
"""Inspect one recorded stack without changing its lifecycle."""
|
|
148
|
+
|
|
129
149
|
if not isinstance(stack, str) or not stack:
|
|
130
150
|
raise DmonConfigError("stack name must not be empty")
|
|
131
151
|
project = self._project()
|
|
@@ -138,6 +158,8 @@ class Dmon:
|
|
|
138
158
|
)
|
|
139
159
|
|
|
140
160
|
def list_stacks(self) -> Tuple[StackResult, ...]:
|
|
161
|
+
"""Return recorded stack results for the selected project."""
|
|
162
|
+
|
|
141
163
|
project = self._project()
|
|
142
164
|
results = []
|
|
143
165
|
with self._operation():
|
|
@@ -154,6 +176,8 @@ class Dmon:
|
|
|
154
176
|
timeout: Optional[float] = None,
|
|
155
177
|
interval: Optional[float] = None,
|
|
156
178
|
) -> Tuple[WaitResult, ...]:
|
|
179
|
+
"""Wait for configured readiness without starting or stopping tasks."""
|
|
180
|
+
|
|
157
181
|
for name, value in (("timeout", timeout), ("interval", interval)):
|
|
158
182
|
if value is not None and (
|
|
159
183
|
not isinstance(value, (int, float))
|
|
@@ -208,6 +232,13 @@ class Dmon:
|
|
|
208
232
|
process_running=lambda meta=meta: check_running(
|
|
209
233
|
meta.pid, meta.create_time
|
|
210
234
|
),
|
|
235
|
+
listener_owned=(
|
|
236
|
+
lambda: task_owns_listener(
|
|
237
|
+
meta, spec.tcp_host, spec.tcp_port
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
if spec.require_owned
|
|
241
|
+
else None,
|
|
211
242
|
)
|
|
212
243
|
)
|
|
213
244
|
return tuple(results)
|
dmon/cli.py
CHANGED
|
@@ -43,6 +43,7 @@ from .constants import (
|
|
|
43
43
|
from .logs import show_stack_logs
|
|
44
44
|
from .inspection import inspect_stack, inspect_task
|
|
45
45
|
from .readiness import ready_spec, wait_for_readiness
|
|
46
|
+
from .repair import request_repair
|
|
46
47
|
from .results import WaitResult
|
|
47
48
|
from .serialization import stack_result_data, task_result_data, wait_result_data
|
|
48
49
|
from .supervisor import (
|
|
@@ -308,6 +309,17 @@ def main():
|
|
|
308
309
|
nargs="?",
|
|
309
310
|
)
|
|
310
311
|
|
|
312
|
+
sp_stack_repair = stack_subparsers.add_parser(
|
|
313
|
+
"repair", help="Repair one exited member without restarting peers"
|
|
314
|
+
)
|
|
315
|
+
sp_stack_repair.add_argument("stack")
|
|
316
|
+
sp_stack_repair.add_argument("task")
|
|
317
|
+
sp_stack_repair.add_argument("--timeout", type=float, default=30)
|
|
318
|
+
sp_stack_repair.add_argument(
|
|
319
|
+
"--operation-id", help="Recheck the same repair without starting another"
|
|
320
|
+
)
|
|
321
|
+
sp_stack_repair.add_argument("--format", choices=("human", "json"), default="human")
|
|
322
|
+
|
|
311
323
|
sp_stack_status = stack_subparsers.add_parser(
|
|
312
324
|
"status",
|
|
313
325
|
help="Show stack and member task status",
|
|
@@ -370,6 +382,7 @@ def main():
|
|
|
370
382
|
sp_stack_up,
|
|
371
383
|
sp_stack_down,
|
|
372
384
|
sp_stack_restart,
|
|
385
|
+
sp_stack_repair,
|
|
373
386
|
sp_stack_status,
|
|
374
387
|
sp_stack_logs,
|
|
375
388
|
sp_stack_list,
|
|
@@ -393,6 +406,7 @@ def main():
|
|
|
393
406
|
"up": sp_stack_up,
|
|
394
407
|
"down": sp_stack_down,
|
|
395
408
|
"restart": sp_stack_restart,
|
|
409
|
+
"repair": sp_stack_repair,
|
|
396
410
|
"status": sp_stack_status,
|
|
397
411
|
"logs": sp_stack_logs,
|
|
398
412
|
"list": sp_stack_list,
|
|
@@ -477,6 +491,16 @@ def main():
|
|
|
477
491
|
meta_path = Path(STACK_META_PATH_TEMPLATE.format(stack=stack))
|
|
478
492
|
except Exception as error:
|
|
479
493
|
sp.error(str(error))
|
|
494
|
+
if args.stack_command == "repair":
|
|
495
|
+
sp.exit(
|
|
496
|
+
request_repair(
|
|
497
|
+
meta_path,
|
|
498
|
+
args.task.lower(),
|
|
499
|
+
args.timeout,
|
|
500
|
+
args.operation_id,
|
|
501
|
+
args.format == "json",
|
|
502
|
+
)
|
|
503
|
+
)
|
|
480
504
|
if args.stack_command == "down":
|
|
481
505
|
sp.exit(stop_stack(meta_path))
|
|
482
506
|
if args.stack_command == "status":
|
|
@@ -557,7 +581,9 @@ def main():
|
|
|
557
581
|
meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
|
|
558
582
|
|
|
559
583
|
# Remove duplicates
|
|
560
|
-
unique_meta_paths = sorted(
|
|
584
|
+
unique_meta_paths = sorted(
|
|
585
|
+
set(Path(p).absolute().resolve() for p in meta_paths)
|
|
586
|
+
)
|
|
561
587
|
|
|
562
588
|
if args.command == "stop":
|
|
563
589
|
sp.exit(stop(unique_meta_paths))
|
dmon/config.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import math
|
|
2
|
+
import ipaddress
|
|
2
3
|
import sys
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Dict, List, Optional, Sequence, Tuple, Union, cast
|
|
@@ -216,7 +217,7 @@ def validate_task(task, name: str) -> DmonTaskConfig:
|
|
|
216
217
|
def validate_ready(ready, name: str) -> Dict[str, object]:
|
|
217
218
|
if not isinstance(ready, dict):
|
|
218
219
|
raise TypeError(f"Task '{name}' 'ready' field must be a table")
|
|
219
|
-
allowed = {"http", "tcp", "command", "timeout", "interval"}
|
|
220
|
+
allowed = {"http", "tcp", "command", "timeout", "interval", "require_owned"}
|
|
220
221
|
unknown = set(ready) - allowed
|
|
221
222
|
if unknown:
|
|
222
223
|
raise TypeError(
|
|
@@ -262,6 +263,26 @@ def validate_ready(ready, name: str) -> Dict[str, object]:
|
|
|
262
263
|
raise TypeError(
|
|
263
264
|
f"Task '{name}' readiness 'tcp' value must contain a host string and valid port"
|
|
264
265
|
)
|
|
266
|
+
if "require_owned" in ready:
|
|
267
|
+
if not isinstance(ready["require_owned"], bool):
|
|
268
|
+
raise TypeError(
|
|
269
|
+
f"Task '{name}' readiness 'require_owned' must be a boolean"
|
|
270
|
+
)
|
|
271
|
+
if ready["require_owned"]:
|
|
272
|
+
host = (
|
|
273
|
+
urlsplit(ready["http"]).hostname
|
|
274
|
+
if "http" in ready
|
|
275
|
+
else ready.get("tcp", {}).get("host", "")
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
local = ipaddress.ip_address(host).is_loopback
|
|
279
|
+
except ValueError:
|
|
280
|
+
local = False
|
|
281
|
+
if not local:
|
|
282
|
+
raise TypeError(
|
|
283
|
+
f"Task '{name}' readiness 'require_owned' needs an HTTP or TCP "
|
|
284
|
+
"probe with a literal loopback IP address"
|
|
285
|
+
)
|
|
265
286
|
for key, default in (("timeout", 30.0), ("interval", 0.2)):
|
|
266
287
|
value = ready.get(key, default)
|
|
267
288
|
if (
|
dmon/control.py
CHANGED
|
@@ -3,9 +3,9 @@ from contextvars import ContextVar
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
import shlex
|
|
7
6
|
import shutil
|
|
8
7
|
import signal
|
|
8
|
+
import socket
|
|
9
9
|
import sys
|
|
10
10
|
import subprocess
|
|
11
11
|
import time
|
|
@@ -19,7 +19,7 @@ from termcolor import colored
|
|
|
19
19
|
|
|
20
20
|
from .constants import DEFAULT_META_DIR, META_SUFFIX, ON_WINDOWS
|
|
21
21
|
from .results import CommandSnapshot, TaskSnapshot
|
|
22
|
-
from .types import DmonTaskConfig, DmonMeta, PathType
|
|
22
|
+
from .types import DmonTaskConfig, DmonMeta, PathType, ProcessIdentity
|
|
23
23
|
from .utils import len_ansi, pad_ansi
|
|
24
24
|
|
|
25
25
|
|
|
@@ -49,6 +49,7 @@ def diagnostic_output(stream: TextIO) -> Iterator[None]:
|
|
|
49
49
|
class StartResult:
|
|
50
50
|
exit_code: int
|
|
51
51
|
meta: Optional[DmonMeta] = None
|
|
52
|
+
error: str = ""
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
def background_process_kwargs() -> dict:
|
|
@@ -102,8 +103,10 @@ def start_single(cfg: DmonTaskConfig) -> int:
|
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
# Python 3.8 on Windows may leave resolve() relative if a parent is missing.
|
|
107
|
+
# Anchor before resolving so newly created records have durable locations.
|
|
108
|
+
meta_path = Path(cfg.meta_path).absolute().resolve()
|
|
109
|
+
log_path = Path(cfg.log_path).absolute().resolve()
|
|
107
110
|
cwd = Path(cfg.cwd).resolve()
|
|
108
111
|
|
|
109
112
|
try:
|
|
@@ -117,7 +120,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
117
120
|
),
|
|
118
121
|
file=sys.stderr,
|
|
119
122
|
)
|
|
120
|
-
return StartResult(1)
|
|
123
|
+
return StartResult(1, error=f"cannot read metadata {meta_path}: {error}")
|
|
121
124
|
if existing_meta and existing_meta.state == "starting":
|
|
122
125
|
print(
|
|
123
126
|
colored(
|
|
@@ -131,7 +134,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
131
134
|
"If the earlier start was interrupted, run 'dmon stop' to remove its reservation.",
|
|
132
135
|
file=sys.stderr,
|
|
133
136
|
)
|
|
134
|
-
return StartResult(1)
|
|
137
|
+
return StartResult(1, error="task is already being started")
|
|
135
138
|
if existing_meta and check_running(existing_meta.pid, existing_meta.create_time):
|
|
136
139
|
print(
|
|
137
140
|
f"{colored('Start failed: meta file already exists', color='red', attrs=['bold'])}",
|
|
@@ -142,8 +145,18 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
142
145
|
"\nRun 'dmon status' / 'dmon list' to check, or 'dmon stop' to stop it.",
|
|
143
146
|
file=sys.stderr,
|
|
144
147
|
)
|
|
145
|
-
return StartResult(1)
|
|
148
|
+
return StartResult(1, error="task is already running")
|
|
146
149
|
if existing_meta:
|
|
150
|
+
if any(
|
|
151
|
+
check_running(child.pid, child.create_time)
|
|
152
|
+
for child in existing_meta.descendants
|
|
153
|
+
) or unverified_group_remains(existing_meta):
|
|
154
|
+
print(
|
|
155
|
+
f"Start failed: task '{cfg.task}' still has residual processes; "
|
|
156
|
+
"stop its owning stack or inspect its preserved metadata first.",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return StartResult(1, error="task still has residual processes")
|
|
147
160
|
print(
|
|
148
161
|
colored(
|
|
149
162
|
f"Removing stale metadata for exited task '{cfg.task}'.",
|
|
@@ -167,7 +180,8 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
167
180
|
),
|
|
168
181
|
file=sys.stderr,
|
|
169
182
|
)
|
|
170
|
-
|
|
183
|
+
# Detailed environment diagnostics belong in the log, not persisted state.
|
|
184
|
+
return StartResult(1, error="environment setup failed; see startup diagnostics")
|
|
171
185
|
|
|
172
186
|
shell = isinstance(cfg.cmd, str)
|
|
173
187
|
|
|
@@ -203,11 +217,11 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
203
217
|
),
|
|
204
218
|
file=sys.stderr,
|
|
205
219
|
)
|
|
206
|
-
return StartResult(1)
|
|
220
|
+
return StartResult(1, error="another dmon process is starting this task")
|
|
207
221
|
|
|
208
222
|
try:
|
|
209
223
|
if cfg.log_rotate:
|
|
210
|
-
rotate_log_path = Path(cfg.rotate_log_path).resolve()
|
|
224
|
+
rotate_log_path = Path(cfg.rotate_log_path).absolute().resolve()
|
|
211
225
|
|
|
212
226
|
meta.rotate_log_path = str(rotate_log_path)
|
|
213
227
|
meta.log_max_size = cfg.log_max_size
|
|
@@ -219,8 +233,9 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
219
233
|
|
|
220
234
|
# use runner to start user process and handle log rotation
|
|
221
235
|
if isinstance(command, str):
|
|
222
|
-
#
|
|
223
|
-
|
|
236
|
+
# Transport shell text as one argument. Splitting and joining
|
|
237
|
+
# would discard quotes, escapes, and significant whitespace.
|
|
238
|
+
cmd = [command]
|
|
224
239
|
else:
|
|
225
240
|
cmd = command
|
|
226
241
|
args = [
|
|
@@ -271,15 +286,31 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
271
286
|
)
|
|
272
287
|
except OSError as error:
|
|
273
288
|
meta_path.unlink(missing_ok=True)
|
|
289
|
+
detail = str(error)
|
|
290
|
+
if not error.filename and isinstance(cfg.cmd, list):
|
|
291
|
+
# CreateProcess may omit the filename. Keep the executable useful
|
|
292
|
+
# for diagnostics without copying potentially sensitive arguments.
|
|
293
|
+
detail = f"command {cfg.cmd[0]!r}: {detail}"
|
|
294
|
+
if (
|
|
295
|
+
isinstance(error, FileNotFoundError)
|
|
296
|
+
and cfg.override_env
|
|
297
|
+
and env is not None
|
|
298
|
+
and "PATH" not in env
|
|
299
|
+
and cwd.is_dir()
|
|
300
|
+
):
|
|
301
|
+
detail += (
|
|
302
|
+
"; override_env=true removes the inherited environment, including PATH. "
|
|
303
|
+
"Use env without override_env for variable overrides, or supply PATH explicitly."
|
|
304
|
+
)
|
|
274
305
|
print(
|
|
275
306
|
colored(
|
|
276
|
-
f"Start failed for task '{cfg.task}': {
|
|
307
|
+
f"Start failed for task '{cfg.task}': {detail}",
|
|
277
308
|
color="red",
|
|
278
309
|
attrs=["bold"],
|
|
279
310
|
),
|
|
280
311
|
file=sys.stderr,
|
|
281
312
|
)
|
|
282
|
-
return StartResult(1)
|
|
313
|
+
return StartResult(1, error=detail)
|
|
283
314
|
|
|
284
315
|
meta.pid = proc.pid
|
|
285
316
|
meta.state = "running"
|
|
@@ -313,7 +344,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
|
|
|
313
344
|
),
|
|
314
345
|
file=sys.stderr,
|
|
315
346
|
)
|
|
316
|
-
return StartResult(1)
|
|
347
|
+
return StartResult(1, error=f"cannot save metadata: {error}")
|
|
317
348
|
|
|
318
349
|
print_status(meta)
|
|
319
350
|
with warnings.catch_warnings():
|
|
@@ -397,6 +428,8 @@ def stop_single(
|
|
|
397
428
|
try:
|
|
398
429
|
proc = psutil.Process(pid)
|
|
399
430
|
except (psutil.NoSuchProcess, ValueError):
|
|
431
|
+
if stop_owned_processes(meta, timeout):
|
|
432
|
+
return 1
|
|
400
433
|
print(
|
|
401
434
|
colored(
|
|
402
435
|
f"Process {pid} not found (already exited); removing stale meta file",
|
|
@@ -411,6 +444,8 @@ def stop_single(
|
|
|
411
444
|
|
|
412
445
|
# check if it's the same process by comparing create_time
|
|
413
446
|
if not check_same_process(proc, meta.create_time):
|
|
447
|
+
if stop_owned_processes(meta, timeout):
|
|
448
|
+
return 1
|
|
414
449
|
print(
|
|
415
450
|
colored(
|
|
416
451
|
f"Warning: PID {pid} exists but create_time does not match (maybe reused by another process); removing stale meta file",
|
|
@@ -423,6 +458,8 @@ def stop_single(
|
|
|
423
458
|
meta_path.unlink(missing_ok=True)
|
|
424
459
|
return 0
|
|
425
460
|
if not process_is_alive(proc):
|
|
461
|
+
if stop_owned_processes(meta, timeout):
|
|
462
|
+
return 1
|
|
426
463
|
print(
|
|
427
464
|
colored(
|
|
428
465
|
f"Process {pid} already exited; removing stale meta file",
|
|
@@ -435,13 +472,119 @@ def stop_single(
|
|
|
435
472
|
meta_path.unlink(missing_ok=True)
|
|
436
473
|
return 0
|
|
437
474
|
|
|
438
|
-
|
|
475
|
+
refresh_descendants(meta)
|
|
476
|
+
ret = stop_owned_processes(meta, timeout)
|
|
439
477
|
print_status(meta)
|
|
440
478
|
if ret == 0:
|
|
441
479
|
meta_path.unlink(missing_ok=True)
|
|
442
480
|
return ret
|
|
443
481
|
|
|
444
482
|
|
|
483
|
+
def owned_identities(meta: DmonMeta) -> List[ProcessIdentity]:
|
|
484
|
+
identities = list(meta.descendants)
|
|
485
|
+
if meta.pid > 0 and meta.create_time >= 0:
|
|
486
|
+
identities.insert(0, ProcessIdentity(meta.pid, meta.create_time))
|
|
487
|
+
return identities
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def refresh_descendants(meta: DmonMeta) -> bool:
|
|
491
|
+
"""Retain observed live identities, even after their parent has exited.
|
|
492
|
+
|
|
493
|
+
This is ancestry evidence within the user's trust domain, not a security
|
|
494
|
+
boundary. Children that escape before observation cannot be recovered by
|
|
495
|
+
guessing from names, ports or process group numbers.
|
|
496
|
+
"""
|
|
497
|
+
observed = set()
|
|
498
|
+
for identity in owned_identities(meta):
|
|
499
|
+
process = get_unique_process(identity.pid, identity.create_time)
|
|
500
|
+
if process is None:
|
|
501
|
+
continue
|
|
502
|
+
if identity.pid != meta.pid:
|
|
503
|
+
observed.add(identity)
|
|
504
|
+
try:
|
|
505
|
+
children = process.children(recursive=True)
|
|
506
|
+
if not check_same_process(process, identity.create_time):
|
|
507
|
+
continue
|
|
508
|
+
for child in children:
|
|
509
|
+
if process_is_alive(child):
|
|
510
|
+
observed.add(ProcessIdentity(child.pid, child.create_time()))
|
|
511
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
512
|
+
continue
|
|
513
|
+
descendants = sorted(observed, key=lambda item: (item.pid, item.create_time))
|
|
514
|
+
changed = descendants != meta.descendants
|
|
515
|
+
meta.descendants = descendants
|
|
516
|
+
return changed
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def task_owns_listener(meta: DmonMeta, host: str, port: int) -> bool:
|
|
520
|
+
"""Positive evidence only; unavailable process sockets never imply ownership.
|
|
521
|
+
|
|
522
|
+
Callers validate a literal loopback endpoint. Never inspect or signal a
|
|
523
|
+
foreign listener, and never treat a successful network probe as identity.
|
|
524
|
+
"""
|
|
525
|
+
root = get_unique_process(meta.pid, meta.create_time)
|
|
526
|
+
if root is None:
|
|
527
|
+
return False
|
|
528
|
+
try:
|
|
529
|
+
processes = [root, *root.children(recursive=True)]
|
|
530
|
+
for process in processes:
|
|
531
|
+
try:
|
|
532
|
+
birth = process.create_time()
|
|
533
|
+
for connection in process.net_connections(kind="tcp"):
|
|
534
|
+
if (
|
|
535
|
+
connection.status == psutil.CONN_LISTEN
|
|
536
|
+
and connection.laddr.port == port
|
|
537
|
+
and (
|
|
538
|
+
connection.laddr.ip == host
|
|
539
|
+
or connection.laddr.ip
|
|
540
|
+
== (
|
|
541
|
+
"::"
|
|
542
|
+
if connection.family == socket.AF_INET6
|
|
543
|
+
else "0.0.0.0"
|
|
544
|
+
)
|
|
545
|
+
and (
|
|
546
|
+
(":" in host) == (connection.family == socket.AF_INET6)
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
and check_running(process.pid, birth)
|
|
550
|
+
and check_running(meta.pid, meta.create_time)
|
|
551
|
+
):
|
|
552
|
+
return True
|
|
553
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
554
|
+
continue
|
|
555
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
556
|
+
pass
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def stop_owned_processes(meta: DmonMeta, timeout: float) -> int:
|
|
561
|
+
"""Stop verified identities; preserve evidence when cleanup is uncertain."""
|
|
562
|
+
failed = False
|
|
563
|
+
for identity in owned_identities(meta):
|
|
564
|
+
process = get_unique_process(identity.pid, identity.create_time)
|
|
565
|
+
if process is not None and terminate_process(process, timeout):
|
|
566
|
+
failed = True
|
|
567
|
+
if unverified_group_remains(meta):
|
|
568
|
+
print(
|
|
569
|
+
f"Task '{meta.task}' exited but its former process group still has "
|
|
570
|
+
"unverified members; refusing to signal them. Metadata was preserved.",
|
|
571
|
+
file=sys.stderr,
|
|
572
|
+
)
|
|
573
|
+
failed = True
|
|
574
|
+
return int(failed)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def unverified_group_remains(meta: DmonMeta) -> bool:
|
|
578
|
+
if ON_WINDOWS or meta.pid <= 0 or not meta.popen_kwargs.get("start_new_session"):
|
|
579
|
+
return False
|
|
580
|
+
try:
|
|
581
|
+
if not check_same_process(psutil.Process(meta.pid), meta.create_time):
|
|
582
|
+
return False # A recycled group leader is not ours.
|
|
583
|
+
except (psutil.NoSuchProcess, ValueError):
|
|
584
|
+
pass
|
|
585
|
+
return process_group_exists(meta.pid)
|
|
586
|
+
|
|
587
|
+
|
|
445
588
|
def terminate_process(proc: psutil.Process, timeout: float) -> int:
|
|
446
589
|
if not ON_WINDOWS:
|
|
447
590
|
try:
|
|
@@ -666,6 +809,11 @@ def task_snapshot(meta: DmonMeta) -> TaskSnapshot:
|
|
|
666
809
|
log_backup_count=meta.log_backup_count,
|
|
667
810
|
rotate_log_max_size=meta.rotate_log_max_size,
|
|
668
811
|
rotate_log_backup_count=meta.rotate_log_backup_count,
|
|
812
|
+
live_descendant_pids=tuple(
|
|
813
|
+
identity.pid
|
|
814
|
+
for identity in meta.descendants
|
|
815
|
+
if check_running(identity.pid, identity.create_time)
|
|
816
|
+
),
|
|
669
817
|
)
|
|
670
818
|
|
|
671
819
|
|
dmon/readiness.py
CHANGED
|
@@ -6,6 +6,7 @@ import subprocess
|
|
|
6
6
|
import time
|
|
7
7
|
from typing import Callable, Mapping, Optional
|
|
8
8
|
from urllib.request import urlopen
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
|
|
10
11
|
from .results import CommandSnapshot, WaitResult
|
|
11
12
|
|
|
@@ -22,6 +23,7 @@ class ReadySpec:
|
|
|
22
23
|
tcp_host: str = ""
|
|
23
24
|
tcp_port: int = 0
|
|
24
25
|
command: CommandSnapshot = ""
|
|
26
|
+
require_owned: bool = False
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def ready_spec(
|
|
@@ -37,11 +39,15 @@ def ready_spec(
|
|
|
37
39
|
interval if interval is not None else ready.get("interval", 0.2)
|
|
38
40
|
)
|
|
39
41
|
if "http" in ready:
|
|
42
|
+
url = urlsplit(str(ready["http"]))
|
|
40
43
|
return ReadySpec(
|
|
41
44
|
"http",
|
|
42
45
|
resolved_timeout,
|
|
43
46
|
resolved_interval,
|
|
44
47
|
http_url=str(ready["http"]),
|
|
48
|
+
tcp_host=url.hostname or "",
|
|
49
|
+
tcp_port=url.port or (443 if url.scheme == "https" else 80),
|
|
50
|
+
require_owned=bool(ready.get("require_owned")),
|
|
45
51
|
)
|
|
46
52
|
if "tcp" in ready:
|
|
47
53
|
tcp = ready["tcp"]
|
|
@@ -52,6 +58,7 @@ def ready_spec(
|
|
|
52
58
|
resolved_interval,
|
|
53
59
|
tcp_host=str(tcp["host"]),
|
|
54
60
|
tcp_port=int(tcp["port"]),
|
|
61
|
+
require_owned=bool(ready.get("require_owned")),
|
|
55
62
|
)
|
|
56
63
|
command = ready.get("command", "")
|
|
57
64
|
if isinstance(command, list):
|
|
@@ -76,10 +83,12 @@ def wait_for_readiness(
|
|
|
76
83
|
env: Optional[Mapping[str, str]],
|
|
77
84
|
process_running: Optional[Check] = None,
|
|
78
85
|
stop_requested: Optional[Check] = None,
|
|
86
|
+
listener_owned: Optional[Check] = None,
|
|
79
87
|
) -> WaitResult:
|
|
80
88
|
started = time.monotonic()
|
|
81
89
|
deadline = started + spec.timeout
|
|
82
90
|
attempts = 0
|
|
91
|
+
ownership_verified = not spec.require_owned
|
|
83
92
|
while True:
|
|
84
93
|
if stop_requested is not None and stop_requested():
|
|
85
94
|
return WaitResult(
|
|
@@ -99,17 +108,43 @@ def wait_for_readiness(
|
|
|
99
108
|
return WaitResult(
|
|
100
109
|
target,
|
|
101
110
|
ready,
|
|
102
|
-
"ready"
|
|
111
|
+
"ready"
|
|
112
|
+
if ready
|
|
113
|
+
else "timeout"
|
|
114
|
+
if ownership_verified
|
|
115
|
+
else "listener-unverified",
|
|
103
116
|
time.monotonic() - started,
|
|
104
117
|
attempts,
|
|
105
118
|
)
|
|
106
119
|
if spec.kind != "process":
|
|
107
120
|
attempts += 1
|
|
108
121
|
attempt_timeout = min(max(1.0, spec.interval), remaining)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
ownership_verified = not spec.require_owned or (
|
|
123
|
+
listener_owned is not None and listener_owned()
|
|
124
|
+
)
|
|
125
|
+
if ownership_verified and probe(
|
|
126
|
+
spec, cwd=cwd, env=env, timeout=attempt_timeout
|
|
127
|
+
):
|
|
128
|
+
if process_running is not None and not process_running():
|
|
129
|
+
return WaitResult(
|
|
130
|
+
target,
|
|
131
|
+
False,
|
|
132
|
+
"process-exited",
|
|
133
|
+
time.monotonic() - started,
|
|
134
|
+
attempts,
|
|
135
|
+
)
|
|
136
|
+
if stop_requested is not None and stop_requested():
|
|
137
|
+
return WaitResult(
|
|
138
|
+
target, False, "stopped", time.monotonic() - started, attempts
|
|
139
|
+
)
|
|
140
|
+
if spec.require_owned and (
|
|
141
|
+
listener_owned is None or not listener_owned()
|
|
142
|
+
):
|
|
143
|
+
ownership_verified = False
|
|
144
|
+
else:
|
|
145
|
+
return WaitResult(
|
|
146
|
+
target, True, "ready", time.monotonic() - started, attempts
|
|
147
|
+
)
|
|
113
148
|
time.sleep(min(spec.interval, max(0.0, deadline - time.monotonic())))
|
|
114
149
|
|
|
115
150
|
|