python-dmon 0.3.1__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/__init__.py +23 -0
- dmon/api.py +295 -0
- dmon/cli.py +493 -15
- dmon/config.py +227 -0
- dmon/constants.py +3 -0
- dmon/control.py +337 -66
- dmon/inspection.py +36 -0
- dmon/logs.py +240 -0
- dmon/readiness.py +183 -0
- dmon/repair.py +303 -0
- dmon/results.py +133 -0
- dmon/serialization.py +28 -0
- dmon/stack_runner.py +18 -0
- dmon/supervisor.py +1112 -0
- dmon/types.py +226 -34
- 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.3.1.dist-info/METADATA +0 -266
- python_dmon-0.3.1.dist-info/RECORD +0 -14
- {python_dmon-0.3.1.dist-info → python_dmon-0.5.0.dist-info}/WHEEL +0 -0
- {python_dmon-0.3.1.dist-info → python_dmon-0.5.0.dist-info}/entry_points.txt +0 -0
dmon/__init__.py
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .api import Dmon, DmonConfigError, DmonError
|
|
2
|
+
from .results import (
|
|
3
|
+
ActionResult,
|
|
4
|
+
BatchResult,
|
|
5
|
+
StackResult,
|
|
6
|
+
StackSnapshot,
|
|
7
|
+
TaskResult,
|
|
8
|
+
TaskSnapshot,
|
|
9
|
+
WaitResult,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ActionResult",
|
|
14
|
+
"BatchResult",
|
|
15
|
+
"Dmon",
|
|
16
|
+
"DmonConfigError",
|
|
17
|
+
"DmonError",
|
|
18
|
+
"StackResult",
|
|
19
|
+
"StackSnapshot",
|
|
20
|
+
"TaskResult",
|
|
21
|
+
"TaskSnapshot",
|
|
22
|
+
"WaitResult",
|
|
23
|
+
]
|
dmon/api.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from io import StringIO
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterator, Optional, Sequence, Tuple, Union
|
|
9
|
+
|
|
10
|
+
from .config import fill_default_paths, get_task_config, load_config
|
|
11
|
+
from .constants import DEFAULT_META_DIR, STACK_META_SUFFIX
|
|
12
|
+
from .control import (
|
|
13
|
+
check_running,
|
|
14
|
+
diagnostic_output,
|
|
15
|
+
restart,
|
|
16
|
+
start_single_result,
|
|
17
|
+
stop_single,
|
|
18
|
+
task_snapshot,
|
|
19
|
+
task_environment,
|
|
20
|
+
task_owns_listener,
|
|
21
|
+
)
|
|
22
|
+
from .results import (
|
|
23
|
+
ActionResult,
|
|
24
|
+
BatchResult,
|
|
25
|
+
StackResult,
|
|
26
|
+
TaskResult,
|
|
27
|
+
WaitResult,
|
|
28
|
+
)
|
|
29
|
+
from .inspection import inspect_stack, inspect_task
|
|
30
|
+
from .readiness import ready_spec, wait_for_readiness
|
|
31
|
+
from .types import DmonMeta
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DmonError(Exception):
|
|
35
|
+
"""Base exception for invalid Python API requests."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DmonConfigError(DmonError):
|
|
39
|
+
"""Configuration could not be loaded or validated."""
|
|
40
|
+
|
|
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
|
+
|
|
50
|
+
def __init__(self, config: Optional[Union[str, os.PathLike]] = None) -> None:
|
|
51
|
+
self.config = str(config) if config is not None else None
|
|
52
|
+
|
|
53
|
+
def start(self, *tasks: str) -> BatchResult:
|
|
54
|
+
"""Start one or more configured tasks in the background."""
|
|
55
|
+
|
|
56
|
+
names, configs, _ = self._tasks(tasks)
|
|
57
|
+
self._validate_environments(configs)
|
|
58
|
+
results = []
|
|
59
|
+
with self._operation():
|
|
60
|
+
for name, config in zip(names, configs):
|
|
61
|
+
result = start_single_result(config)
|
|
62
|
+
snapshot = (
|
|
63
|
+
task_snapshot(result.meta) if result.meta is not None else None
|
|
64
|
+
)
|
|
65
|
+
results.append(
|
|
66
|
+
ActionResult(
|
|
67
|
+
action="start",
|
|
68
|
+
name=name,
|
|
69
|
+
ok=result.exit_code == 0,
|
|
70
|
+
exit_code=result.exit_code,
|
|
71
|
+
snapshot=snapshot,
|
|
72
|
+
error=result.error,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return BatchResult("start", tuple(results))
|
|
76
|
+
|
|
77
|
+
def stop(self, *tasks: str) -> BatchResult:
|
|
78
|
+
"""Stop one or more configured tasks using their recorded identities."""
|
|
79
|
+
|
|
80
|
+
names, configs, _ = self._tasks(tasks)
|
|
81
|
+
results = []
|
|
82
|
+
with self._operation():
|
|
83
|
+
for name, config in zip(names, configs):
|
|
84
|
+
exit_code = stop_single(config.meta_path)
|
|
85
|
+
results.append(
|
|
86
|
+
ActionResult(
|
|
87
|
+
action="stop",
|
|
88
|
+
name=name,
|
|
89
|
+
ok=exit_code == 0,
|
|
90
|
+
exit_code=exit_code,
|
|
91
|
+
error="" if exit_code == 0 else "task did not stop",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return BatchResult("stop", tuple(results))
|
|
95
|
+
|
|
96
|
+
def restart(self, *tasks: str) -> BatchResult:
|
|
97
|
+
"""Restart one or more configured tasks."""
|
|
98
|
+
|
|
99
|
+
names, configs, _ = self._tasks(tasks)
|
|
100
|
+
self._validate_environments(configs)
|
|
101
|
+
results = []
|
|
102
|
+
with self._operation():
|
|
103
|
+
for name, config in zip(names, configs):
|
|
104
|
+
exit_code = restart([config])
|
|
105
|
+
meta = self._load_task_meta(Path(config.meta_path))
|
|
106
|
+
snapshot = task_snapshot(meta) if meta is not None else None
|
|
107
|
+
results.append(
|
|
108
|
+
ActionResult(
|
|
109
|
+
action="restart",
|
|
110
|
+
name=name,
|
|
111
|
+
ok=exit_code == 0,
|
|
112
|
+
exit_code=exit_code,
|
|
113
|
+
snapshot=snapshot,
|
|
114
|
+
error="" if exit_code == 0 else "task did not restart",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return BatchResult("restart", tuple(results))
|
|
118
|
+
|
|
119
|
+
def status(self, task: Optional[str] = None) -> TaskResult:
|
|
120
|
+
"""Inspect one configured task without changing its lifecycle."""
|
|
121
|
+
|
|
122
|
+
names, configs, _ = self._tasks(() if task is None else (task,))
|
|
123
|
+
if len(names) != 1:
|
|
124
|
+
raise DmonConfigError("status requires exactly one task")
|
|
125
|
+
with self._operation():
|
|
126
|
+
return self._task_result(names[0], Path(configs[0].meta_path))
|
|
127
|
+
|
|
128
|
+
def list_tasks(self) -> Tuple[TaskResult, ...]:
|
|
129
|
+
"""Return recorded task results for the selected project."""
|
|
130
|
+
|
|
131
|
+
project = self._project()
|
|
132
|
+
results = []
|
|
133
|
+
with self._operation():
|
|
134
|
+
meta_dir = project / DEFAULT_META_DIR
|
|
135
|
+
if meta_dir.is_dir():
|
|
136
|
+
for path in sorted(meta_dir.glob("*.meta.json")):
|
|
137
|
+
results.append(
|
|
138
|
+
inspect_task(
|
|
139
|
+
path.name[: -len(".meta.json")],
|
|
140
|
+
path,
|
|
141
|
+
require_running=False,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return tuple(results)
|
|
145
|
+
|
|
146
|
+
def stack_status(self, stack: str) -> StackResult:
|
|
147
|
+
"""Inspect one recorded stack without changing its lifecycle."""
|
|
148
|
+
|
|
149
|
+
if not isinstance(stack, str) or not stack:
|
|
150
|
+
raise DmonConfigError("stack name must not be empty")
|
|
151
|
+
project = self._project()
|
|
152
|
+
name = stack.lower()
|
|
153
|
+
with self._operation():
|
|
154
|
+
return inspect_stack(
|
|
155
|
+
name,
|
|
156
|
+
project / DEFAULT_META_DIR / f"{name}{STACK_META_SUFFIX}",
|
|
157
|
+
require_running=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def list_stacks(self) -> Tuple[StackResult, ...]:
|
|
161
|
+
"""Return recorded stack results for the selected project."""
|
|
162
|
+
|
|
163
|
+
project = self._project()
|
|
164
|
+
results = []
|
|
165
|
+
with self._operation():
|
|
166
|
+
meta_dir = project / DEFAULT_META_DIR
|
|
167
|
+
if meta_dir.is_dir():
|
|
168
|
+
for path in sorted(meta_dir.glob(f"*{STACK_META_SUFFIX}")):
|
|
169
|
+
name = path.name[: -len(STACK_META_SUFFIX)]
|
|
170
|
+
results.append(inspect_stack(name, path, require_running=False))
|
|
171
|
+
return tuple(results)
|
|
172
|
+
|
|
173
|
+
def wait(
|
|
174
|
+
self,
|
|
175
|
+
*tasks: str,
|
|
176
|
+
timeout: Optional[float] = None,
|
|
177
|
+
interval: Optional[float] = None,
|
|
178
|
+
) -> Tuple[WaitResult, ...]:
|
|
179
|
+
"""Wait for configured readiness without starting or stopping tasks."""
|
|
180
|
+
|
|
181
|
+
for name, value in (("timeout", timeout), ("interval", interval)):
|
|
182
|
+
if value is not None and (
|
|
183
|
+
not isinstance(value, (int, float))
|
|
184
|
+
or isinstance(value, bool)
|
|
185
|
+
or not math.isfinite(value)
|
|
186
|
+
or value <= 0
|
|
187
|
+
):
|
|
188
|
+
raise DmonConfigError(f"{name} must be finite and greater than zero")
|
|
189
|
+
names, configs, _ = self._tasks(tasks)
|
|
190
|
+
self._validate_environments(configs)
|
|
191
|
+
results = []
|
|
192
|
+
with self._operation():
|
|
193
|
+
for name, config in zip(names, configs):
|
|
194
|
+
if not config.ready:
|
|
195
|
+
results.append(
|
|
196
|
+
WaitResult(
|
|
197
|
+
name,
|
|
198
|
+
False,
|
|
199
|
+
"invalid",
|
|
200
|
+
0.0,
|
|
201
|
+
0,
|
|
202
|
+
"task has no readiness probe",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
meta = DmonMeta.load(config.meta_path)
|
|
208
|
+
except (OSError, ValueError, TypeError) as error:
|
|
209
|
+
results.append(
|
|
210
|
+
WaitResult(name, False, "metadata-error", 0.0, 0, str(error))
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
if meta is None:
|
|
214
|
+
results.append(
|
|
215
|
+
WaitResult(
|
|
216
|
+
name,
|
|
217
|
+
False,
|
|
218
|
+
"not-running",
|
|
219
|
+
0.0,
|
|
220
|
+
0,
|
|
221
|
+
"task metadata not found",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
continue
|
|
225
|
+
spec = ready_spec(config.ready, timeout=timeout, interval=interval)
|
|
226
|
+
results.append(
|
|
227
|
+
wait_for_readiness(
|
|
228
|
+
name,
|
|
229
|
+
spec,
|
|
230
|
+
cwd=config.cwd,
|
|
231
|
+
env=task_environment(config),
|
|
232
|
+
process_running=lambda meta=meta: check_running(
|
|
233
|
+
meta.pid, meta.create_time
|
|
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,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return tuple(results)
|
|
245
|
+
|
|
246
|
+
def _tasks(self, tasks: Sequence[str]):
|
|
247
|
+
if any(not isinstance(task, str) or not task for task in tasks):
|
|
248
|
+
raise DmonConfigError("task names must be non-empty strings")
|
|
249
|
+
try:
|
|
250
|
+
names, configs, path = get_task_config(tasks, self.config)
|
|
251
|
+
fill_default_paths(configs)
|
|
252
|
+
except (OSError, ValueError, TypeError) as error:
|
|
253
|
+
raise DmonConfigError(str(error)) from error
|
|
254
|
+
project = path.parent.resolve()
|
|
255
|
+
for config in configs:
|
|
256
|
+
config.cwd = self._resolve_path(project, config.cwd or ".")
|
|
257
|
+
config.meta_path = self._resolve_path(project, config.meta_path)
|
|
258
|
+
config.log_path = self._resolve_path(project, config.log_path)
|
|
259
|
+
config.rotate_log_path = self._resolve_path(project, config.rotate_log_path)
|
|
260
|
+
config.env_files = [
|
|
261
|
+
self._resolve_path(project, value) for value in config.env_files
|
|
262
|
+
]
|
|
263
|
+
return names, configs, project
|
|
264
|
+
|
|
265
|
+
def _project(self) -> Path:
|
|
266
|
+
try:
|
|
267
|
+
_, path = load_config(self.config)
|
|
268
|
+
except (OSError, ValueError, TypeError) as error:
|
|
269
|
+
raise DmonConfigError(str(error)) from error
|
|
270
|
+
return path.parent
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _validate_environments(configs) -> None:
|
|
274
|
+
try:
|
|
275
|
+
for config in configs:
|
|
276
|
+
task_environment(config)
|
|
277
|
+
except (OSError, ValueError) as error:
|
|
278
|
+
raise DmonConfigError(str(error)) from error
|
|
279
|
+
|
|
280
|
+
@contextmanager
|
|
281
|
+
def _operation(self) -> Iterator[None]:
|
|
282
|
+
with diagnostic_output(StringIO()):
|
|
283
|
+
yield
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _resolve_path(project: Path, value: str) -> str:
|
|
287
|
+
path = Path(value)
|
|
288
|
+
return str(path.resolve() if path.is_absolute() else (project / path).resolve())
|
|
289
|
+
|
|
290
|
+
def _task_result(self, name: str, path: Path) -> TaskResult:
|
|
291
|
+
return inspect_task(name, path, require_running=True)
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _load_task_meta(path: Path) -> Optional[DmonMeta]:
|
|
295
|
+
return DmonMeta.load(path)
|