dirigent-cli 0.9.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.
- dirigent_cli/__init__.py +5 -0
- dirigent_cli/aliases.py +41 -0
- dirigent_cli/commands.py +2525 -0
- dirigent_cli/context.py +136 -0
- dirigent_cli/formatters.py +158 -0
- dirigent_cli/graph.py +109 -0
- dirigent_cli/health.py +294 -0
- dirigent_cli/local.py +790 -0
- dirigent_cli/main.py +1169 -0
- dirigent_cli/output.py +543 -0
- dirigent_cli/params.py +389 -0
- dirigent_cli/profiles.py +221 -0
- dirigent_cli/project.py +643 -0
- dirigent_cli/py.typed +0 -0
- dirigent_cli/reaper.py +115 -0
- dirigent_cli/scaffold.py +63 -0
- dirigent_cli/schemas.py +85 -0
- dirigent_cli/sources.py +76 -0
- dirigent_cli/stream.py +180 -0
- dirigent_cli/summaries.py +420 -0
- dirigent_cli/templates/pack/README.md.tmpl +23 -0
- dirigent_cli/templates/pack/__init__.py.tmpl +24 -0
- dirigent_cli/templates/pack/operator.py.tmpl +34 -0
- dirigent_cli/templates/pack/pyproject.toml.tmpl +21 -0
- dirigent_cli/templates/pack/test_plugin.py.tmpl +21 -0
- dirigent_cli/timing.py +322 -0
- dirigent_cli/triggers.py +631 -0
- dirigent_cli-0.9.0.dist-info/METADATA +24 -0
- dirigent_cli-0.9.0.dist-info/RECORD +32 -0
- dirigent_cli-0.9.0.dist-info/WHEEL +4 -0
- dirigent_cli-0.9.0.dist-info/entry_points.txt +4 -0
- dirigent_cli-0.9.0.dist-info/licenses/LICENSE +18 -0
dirigent_cli/health.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Process-side health checks: the instance this shell resolves, as seen from here.
|
|
2
|
+
|
|
3
|
+
Each check answers one question without a token: does the configured database answer and
|
|
4
|
+
hold the right schema, are workers beating, are schedules firing on time, does the server
|
|
5
|
+
answer. Bare, ``dg health`` checks the whole instance -- and a machine with no instance on
|
|
6
|
+
it says so in one line instead of reporting the absence of everything, part by part.
|
|
7
|
+
|
|
8
|
+
A named check asserts its component is here, so finding none of it fails; that is the form
|
|
9
|
+
a container's ``HEALTHCHECK`` runs, where the environment is the instance and exactly one
|
|
10
|
+
component lives. The named ``worker`` check is scoped to this hostname for the same reason,
|
|
11
|
+
while the bare form asks about every worker the instance has.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import socket
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import httpx2
|
|
22
|
+
import sqlalchemy as sa
|
|
23
|
+
from pydantic import BaseModel, ConfigDict
|
|
24
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
25
|
+
|
|
26
|
+
from dirigent_cli.profiles import ProfileError, resolve_endpoint
|
|
27
|
+
from dirigent_client.enums import WorkerStatus
|
|
28
|
+
from dirigent_core.config import Settings, redacted_url
|
|
29
|
+
from dirigent_core.database import create_engine, create_session_factory
|
|
30
|
+
from dirigent_core.migrations import current_revision_async, head_revision
|
|
31
|
+
from dirigent_core.models import Schedule, Worker, utcnow
|
|
32
|
+
|
|
33
|
+
#: How many beats a worker may miss before it counts as not working.
|
|
34
|
+
MISSED_BEATS = 6
|
|
35
|
+
|
|
36
|
+
READY_TIMEOUT = 5.0
|
|
37
|
+
LIVE_PATH = "/health"
|
|
38
|
+
READY_PATH = "/health/ready"
|
|
39
|
+
HTTP_OK = 200
|
|
40
|
+
|
|
41
|
+
type Verdict = Literal["healthy", "unhealthy", "absent"]
|
|
42
|
+
"""What a check decided. ``absent`` is "there is none of this", not "it is broken"."""
|
|
43
|
+
|
|
44
|
+
type Probe = Literal["liveness", "readiness"]
|
|
45
|
+
"""Whether a check asked "are you there" or "can you do your job"."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Check(BaseModel):
|
|
49
|
+
"""What one check found: which check it was, what it decided, and why."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(frozen=True)
|
|
52
|
+
|
|
53
|
+
check: str
|
|
54
|
+
status: Verdict
|
|
55
|
+
detail: str
|
|
56
|
+
probe: Probe = "readiness"
|
|
57
|
+
|
|
58
|
+
def failed(self, *, asserted: bool) -> bool:
|
|
59
|
+
"""Report whether this ends the command in a failure.
|
|
60
|
+
|
|
61
|
+
Absent fails only when the caller named the component, because naming it is the
|
|
62
|
+
assertion that it should be there.
|
|
63
|
+
"""
|
|
64
|
+
return self.status == "unhealthy" or (asserted and self.status == "absent")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def where_database(settings: Settings) -> str:
|
|
68
|
+
"""Name the database a person would recognise: the file for SQLite, the URL otherwise."""
|
|
69
|
+
target = settings.sqlite_path
|
|
70
|
+
return str(target) if target is not None else redacted_url(settings)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def named_server(*, url: str | None = None, profile: str | None = None) -> str | None:
|
|
74
|
+
"""The server this shell names through a flag, ``DG_URL`` or a profile, or None.
|
|
75
|
+
|
|
76
|
+
None means nothing was named, so the only server worth asking about is this host's own
|
|
77
|
+
loopback -- which is what a container is.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
endpoint = resolve_endpoint(url=url, profile=profile)
|
|
81
|
+
except ProfileError:
|
|
82
|
+
return None
|
|
83
|
+
return None if endpoint.source == "default" else endpoint.url
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def latest_heartbeat(session: AsyncSession, hostname: str) -> datetime | None:
|
|
87
|
+
"""Read the most recent heartbeat any worker on this host has written."""
|
|
88
|
+
# Must match on hostname, not worker name: a worker names itself hostname-pid, and this
|
|
89
|
+
# check runs in a different process with a different pid.
|
|
90
|
+
rows = await session.execute(sa.select(Worker.last_seen_at).where(Worker.hostname == hostname))
|
|
91
|
+
return max(rows.scalars(), default=None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_beating(seen: datetime | None, *, heartbeat: timedelta, now: datetime | None = None) -> bool:
|
|
95
|
+
"""Say whether a heartbeat that old still counts as a worker that is working."""
|
|
96
|
+
if seen is None:
|
|
97
|
+
return False
|
|
98
|
+
return (now or utcnow()) - seen < heartbeat * MISSED_BEATS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def worker_check(session: AsyncSession, settings: Settings, hostname: str | None = None) -> Check:
|
|
102
|
+
"""Report whether workers are beating: this host's when one is named, the instance's else.
|
|
103
|
+
|
|
104
|
+
A clean shutdown writes ``stopped``, so a stopped worker is history rather than a
|
|
105
|
+
fault: only workers that claim to be alive and have gone silent count against this.
|
|
106
|
+
"""
|
|
107
|
+
query = sa.select(Worker.last_seen_at, Worker.status)
|
|
108
|
+
if hostname is not None:
|
|
109
|
+
query = query.where(Worker.hostname == hostname)
|
|
110
|
+
rows = (await session.execute(query)).all()
|
|
111
|
+
place = f" on {hostname}" if hostname is not None else ""
|
|
112
|
+
if not rows:
|
|
113
|
+
return Check(check="worker", status="absent", detail=f"no worker has ever registered{place}")
|
|
114
|
+
alive = [seen for seen, status in rows if status != WorkerStatus.STOPPED]
|
|
115
|
+
if not alive:
|
|
116
|
+
return Check(check="worker", status="absent", detail=f"every worker{place} has stopped")
|
|
117
|
+
beating = [seen for seen in alive if is_beating(seen, heartbeat=settings.heartbeat)]
|
|
118
|
+
if not beating:
|
|
119
|
+
quiet = int((utcnow() - max(alive)).total_seconds())
|
|
120
|
+
noun = f"the worker{place}" if len(alive) == 1 else f"all {len(alive)} workers{place}"
|
|
121
|
+
return Check(check="worker", status="unhealthy", detail=f"{noun} went silent, the last {quiet}s ago")
|
|
122
|
+
if hostname is not None:
|
|
123
|
+
return Check(check="worker", status="healthy", detail=f"a worker on {hostname} is beating")
|
|
124
|
+
return Check(check="worker", status="healthy", detail=f"{len(beating)} of {len(alive)} workers beating")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def scheduler_check(session: AsyncSession, settings: Settings, *, now: datetime | None = None) -> Check:
|
|
128
|
+
"""Report whether the schedules that should have fired have fired.
|
|
129
|
+
|
|
130
|
+
Leadership is a session-scoped advisory lock, so no other process can see who holds it.
|
|
131
|
+
What is visible is the work: a schedule later than the misfire grace means nothing is
|
|
132
|
+
ticking, whichever process was meant to be doing it.
|
|
133
|
+
"""
|
|
134
|
+
moment = now or utcnow()
|
|
135
|
+
rows = await session.execute(
|
|
136
|
+
sa.select(Schedule.next_fire_at).where(Schedule.paused.is_(False), Schedule.next_fire_at.is_not(None))
|
|
137
|
+
)
|
|
138
|
+
waiting = sorted(when for when in rows.scalars() if when is not None)
|
|
139
|
+
if not waiting:
|
|
140
|
+
return Check(check="scheduler", status="healthy", detail="no schedule is waiting to fire")
|
|
141
|
+
threshold = moment - settings.scheduler_misfire_grace
|
|
142
|
+
overdue = [when for when in waiting if when < threshold]
|
|
143
|
+
if overdue:
|
|
144
|
+
late = int((moment - overdue[0]).total_seconds())
|
|
145
|
+
return Check(
|
|
146
|
+
check="scheduler",
|
|
147
|
+
status="unhealthy",
|
|
148
|
+
detail=f"{len(overdue)} of {len(waiting)} schedules overdue, the oldest by {late}s: nothing is firing them",
|
|
149
|
+
)
|
|
150
|
+
return Check(check="scheduler", status="healthy", detail=f"{len(waiting)} schedules waiting, none overdue")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def server_check(
|
|
154
|
+
settings: Settings, *, server: str | None = None, probe: Probe = "readiness", timeout: float = READY_TIMEOUT
|
|
155
|
+
) -> Check:
|
|
156
|
+
"""Ask the server -- the one this shell names, or this host's own -- if it is alive or ready.
|
|
157
|
+
|
|
158
|
+
Liveness answers without touching a dependency, so it separates "the process is gone"
|
|
159
|
+
from "the process is up and cannot reach its database" -- which is the case anyone
|
|
160
|
+
actually cares about.
|
|
161
|
+
"""
|
|
162
|
+
base = server or f"http://127.0.0.1:{settings.port}"
|
|
163
|
+
where = base.split("://", 1)[-1]
|
|
164
|
+
path = READY_PATH if probe == "readiness" else LIVE_PATH
|
|
165
|
+
try:
|
|
166
|
+
response = httpx2.get(f"{base}{path}", timeout=timeout)
|
|
167
|
+
except httpx2.ConnectError:
|
|
168
|
+
detail = f"nothing is answering at {where}" if server else f"nothing is serving {where}"
|
|
169
|
+
return Check(check="server", status="absent", detail=detail, probe=probe)
|
|
170
|
+
except httpx2.HTTPError as error:
|
|
171
|
+
return Check(
|
|
172
|
+
check="server", status="unhealthy", detail=f"the server at {where}: {type(error).__name__}", probe=probe
|
|
173
|
+
)
|
|
174
|
+
if response.status_code != HTTP_OK:
|
|
175
|
+
return Check(
|
|
176
|
+
check="server",
|
|
177
|
+
status="unhealthy",
|
|
178
|
+
detail=f"the server at {where} answered {response.status_code} to {path}",
|
|
179
|
+
probe=probe,
|
|
180
|
+
)
|
|
181
|
+
word = "ready" if probe == "readiness" else "alive"
|
|
182
|
+
return Check(check="server", status="healthy", detail=f"the server at {where} is {word}", probe=probe)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def database_check(settings: Settings) -> Check:
|
|
186
|
+
"""Say whether the configured database answers, and holds the schema this code expects.
|
|
187
|
+
|
|
188
|
+
Reachable is not the same as usable: a database nobody has migrated answers ``SELECT 1``
|
|
189
|
+
and has no tables, and one left behind by a half-finished upgrade answers everything and
|
|
190
|
+
is missing a column. Both are what a readiness check is for.
|
|
191
|
+
"""
|
|
192
|
+
where = where_database(settings)
|
|
193
|
+
# Opening a SQLite URL creates the file, so a machine with no instance on it would be
|
|
194
|
+
# given an empty database by the command that came to look at one.
|
|
195
|
+
target = settings.sqlite_path
|
|
196
|
+
if target is not None and not target.exists():
|
|
197
|
+
return Check(check="database", status="absent", detail=f"no database at {target}")
|
|
198
|
+
try:
|
|
199
|
+
stamped = await current_revision_async(settings)
|
|
200
|
+
except Exception as error: # any driver error means the same thing to whoever asked
|
|
201
|
+
return Check(check="database", status="unhealthy", detail=f"{where} is unreachable: {type(error).__name__}")
|
|
202
|
+
if stamped is None:
|
|
203
|
+
return Check(
|
|
204
|
+
check="database",
|
|
205
|
+
status="unhealthy",
|
|
206
|
+
detail=f"the database at {where} answers, but holds no schema; run `dg db upgrade`",
|
|
207
|
+
)
|
|
208
|
+
head = head_revision(settings)
|
|
209
|
+
if head is not None and stamped != head:
|
|
210
|
+
return Check(
|
|
211
|
+
check="database",
|
|
212
|
+
status="unhealthy",
|
|
213
|
+
detail=f"the database at {where} is at schema {stamped}, and this dirigent expects {head};"
|
|
214
|
+
" run `dg db upgrade`",
|
|
215
|
+
)
|
|
216
|
+
return Check(check="database", status="healthy", detail=f"the database at {where} answers, schema {stamped}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def _opened[T](settings: Settings, work: Callable[[AsyncSession], Awaitable[T]]) -> T:
|
|
220
|
+
"""Run one piece of work against the configured database, and close it again."""
|
|
221
|
+
engine = create_engine(settings)
|
|
222
|
+
try:
|
|
223
|
+
async with create_session_factory(engine)() as session:
|
|
224
|
+
return await work(session)
|
|
225
|
+
finally:
|
|
226
|
+
await engine.dispose()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _instance(settings: Settings) -> list[Check]:
|
|
230
|
+
"""Run every database-backed check: the database itself, then what it knows."""
|
|
231
|
+
database = await database_check(settings)
|
|
232
|
+
if database.status != "healthy":
|
|
233
|
+
# Workers and schedules cannot be read through a database with no schema, and
|
|
234
|
+
# three failures where there is one fault reads as three faults.
|
|
235
|
+
return [database]
|
|
236
|
+
engine = create_engine(settings)
|
|
237
|
+
try:
|
|
238
|
+
async with create_session_factory(engine)() as session:
|
|
239
|
+
return [database, await worker_check(session, settings), await scheduler_check(session, settings)]
|
|
240
|
+
finally:
|
|
241
|
+
await engine.dispose()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def every_check(settings: Settings, *, server: str | None = None) -> list[Check]:
|
|
245
|
+
"""Run every check of the instance this shell resolves, in the order an operator reads.
|
|
246
|
+
|
|
247
|
+
A database that is simply not there ends it early: on a machine with no instance and no
|
|
248
|
+
named server there is nothing else worth probing, and the verdict says so in one line.
|
|
249
|
+
"""
|
|
250
|
+
found = asyncio.run(_instance(settings))
|
|
251
|
+
if found[0].status == "absent" and server is None:
|
|
252
|
+
return found
|
|
253
|
+
return [*found, server_check(settings, server=server)]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def worker_health(settings: Settings, hostname: str | None = None) -> Check:
|
|
257
|
+
"""Run the worker check for this host, which is what a worker's HEALTHCHECK asserts."""
|
|
258
|
+
host = hostname or socket.gethostname()
|
|
259
|
+
return asyncio.run(_opened(settings, lambda session: worker_check(session, settings, host)))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def scheduler_health(settings: Settings) -> Check:
|
|
263
|
+
"""Run the scheduler check against the configured database."""
|
|
264
|
+
return asyncio.run(_opened(settings, lambda session: scheduler_check(session, settings)))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def database_health(settings: Settings) -> Check:
|
|
268
|
+
"""Run the database check against the configured database."""
|
|
269
|
+
return asyncio.run(database_check(settings))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def verdict(checks: Sequence[Check]) -> dict[str, Any]:
|
|
273
|
+
"""Sum up what was found, so the last line is the answer rather than an addition problem."""
|
|
274
|
+
counted = Counter(check.status for check in checks)
|
|
275
|
+
broken = [check.check for check in checks if check.status == "unhealthy"]
|
|
276
|
+
absent = {check.check: check for check in checks if check.status == "absent"}
|
|
277
|
+
if broken:
|
|
278
|
+
message = f"{len(broken)} of {len(checks)} checks failed: {', '.join(broken)}"
|
|
279
|
+
elif "database" in absent:
|
|
280
|
+
message = f"there is no instance here: {absent['database'].detail}, and no server was named"
|
|
281
|
+
elif {"worker", "server"} <= absent.keys():
|
|
282
|
+
message = "an instance's database is here, and nothing is running against it"
|
|
283
|
+
elif absent:
|
|
284
|
+
message = f"nothing is broken; there is no {', no '.join(absent)}"
|
|
285
|
+
else:
|
|
286
|
+
message = "everything checked is healthy"
|
|
287
|
+
return {
|
|
288
|
+
"level": "error" if broken else "info",
|
|
289
|
+
"message": message,
|
|
290
|
+
"checked": len(checks),
|
|
291
|
+
"healthy": counted["healthy"],
|
|
292
|
+
"absent": counted["absent"],
|
|
293
|
+
"unhealthy": counted["unhealthy"],
|
|
294
|
+
}
|