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/main.py
ADDED
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
"""The dirigent command line: installed as `dirigent` and as the short alias `dg`."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated, Any, cast
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
from typer import _click, rich_utils
|
|
15
|
+
from typer.core import TyperGroup
|
|
16
|
+
|
|
17
|
+
from dirigent_cli import commands, triggers
|
|
18
|
+
from dirigent_cli.aliases import add_alias, add_list_aliases
|
|
19
|
+
from dirigent_cli.context import CliState, state_of
|
|
20
|
+
from dirigent_cli.formatters import DEFAULT, names
|
|
21
|
+
from dirigent_cli.output import configure, detail_mode, emit_fact, emit_problem, emit_rendered, refuse
|
|
22
|
+
from dirigent_client.enums import UserRole
|
|
23
|
+
from dirigent_core import migrations
|
|
24
|
+
from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
|
|
25
|
+
from dirigent_core.logging import LOG_FORMAT_ENV, configure_logging, silence_stdout
|
|
26
|
+
from dirigent_core.protocol import FORMATS, Format, Record, as_json, make
|
|
27
|
+
from dirigent_core.telemetry import configure_telemetry
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import uvicorn
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
33
|
+
|
|
34
|
+
from dirigent_cli.health import Check
|
|
35
|
+
from dirigent_cli.stream import Sink
|
|
36
|
+
from dirigent_common import JsonMap
|
|
37
|
+
from dirigent_core import retention
|
|
38
|
+
from dirigent_core.worker import Worker
|
|
39
|
+
|
|
40
|
+
#: Help is capped rather than stretched: a panel the width of a wide terminal is unreadable.
|
|
41
|
+
rich_utils.MAX_WIDTH = 100
|
|
42
|
+
|
|
43
|
+
RUN_PANEL = "Run"
|
|
44
|
+
DEFINE_PANEL = "Define"
|
|
45
|
+
CONNECT_PANEL = "Connect"
|
|
46
|
+
TRIGGER_PANEL = "Triggers"
|
|
47
|
+
PROCESS_PANEL = "Processes"
|
|
48
|
+
ADMIN_PANEL = "Administration"
|
|
49
|
+
|
|
50
|
+
PANEL_ORDER = (RUN_PANEL, DEFINE_PANEL, CONNECT_PANEL, TRIGGER_PANEL, PROCESS_PANEL, ADMIN_PANEL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PanelOrderedGroup(TyperGroup):
|
|
54
|
+
"""A group whose help panels follow PANEL_ORDER.
|
|
55
|
+
|
|
56
|
+
Typer renders a panel where its first command appears and lists every plain command
|
|
57
|
+
before every subcommand group, so panel order otherwise depends on which entries
|
|
58
|
+
happen to be groups.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Typer vendors its own click; the signature must match the base class or this is a
|
|
62
|
+
# new method rather than an override.
|
|
63
|
+
def list_commands(self, ctx: _click.Context) -> list[str]:
|
|
64
|
+
"""List the commands panel by panel, keeping registration order inside each panel."""
|
|
65
|
+
|
|
66
|
+
def rank(name: str) -> int:
|
|
67
|
+
command = self.get_command(ctx, name)
|
|
68
|
+
panel = getattr(command, "rich_help_panel", None)
|
|
69
|
+
return PANEL_ORDER.index(panel) if panel in PANEL_ORDER else len(PANEL_ORDER)
|
|
70
|
+
|
|
71
|
+
return sorted(super().list_commands(ctx), key=rank)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
app = typer.Typer(
|
|
75
|
+
name="dirigent",
|
|
76
|
+
cls=PanelOrderedGroup,
|
|
77
|
+
help="A pipeline orchestrator. Pipelines are documents; blocks are what they run.",
|
|
78
|
+
no_args_is_help=True,
|
|
79
|
+
add_completion=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
db_app = typer.Typer(name="db", help="Database schema management.", no_args_is_help=True)
|
|
83
|
+
config_app = typer.Typer(name="config", help="Inspect the effective configuration.", no_args_is_help=True)
|
|
84
|
+
health_app = typer.Typer(name="health", help="Process-side checks: everything this host runs, or one part.")
|
|
85
|
+
docker_app = typer.Typer(name="docker", help="The docker daemon this host's worker uses.", no_args_is_help=True)
|
|
86
|
+
|
|
87
|
+
app.add_typer(commands.runs_app, rich_help_panel=RUN_PANEL)
|
|
88
|
+
app.add_typer(commands.pipeline_app, rich_help_panel=DEFINE_PANEL)
|
|
89
|
+
app.add_typer(commands.blocks_app, rich_help_panel=DEFINE_PANEL)
|
|
90
|
+
app.add_typer(commands.schema_app, rich_help_panel=DEFINE_PANEL)
|
|
91
|
+
app.add_typer(commands.connection_app, rich_help_panel=CONNECT_PANEL)
|
|
92
|
+
app.add_typer(triggers.schedule_app, rich_help_panel=TRIGGER_PANEL)
|
|
93
|
+
app.add_typer(triggers.webhook_app, rich_help_panel=TRIGGER_PANEL)
|
|
94
|
+
app.add_typer(triggers.trigger_document_app, rich_help_panel=TRIGGER_PANEL)
|
|
95
|
+
app.add_typer(triggers.alerts_app, rich_help_panel=TRIGGER_PANEL)
|
|
96
|
+
app.add_typer(health_app, rich_help_panel=PROCESS_PANEL)
|
|
97
|
+
app.add_typer(docker_app, rich_help_panel=PROCESS_PANEL)
|
|
98
|
+
app.add_typer(db_app, rich_help_panel=ADMIN_PANEL)
|
|
99
|
+
app.add_typer(config_app, rich_help_panel=ADMIN_PANEL)
|
|
100
|
+
app.add_typer(commands.auth_app, rich_help_panel=ADMIN_PANEL)
|
|
101
|
+
app.add_typer(commands.system_app, rich_help_panel=ADMIN_PANEL)
|
|
102
|
+
app.add_typer(commands.admin_app, rich_help_panel=ADMIN_PANEL)
|
|
103
|
+
|
|
104
|
+
app.command("run", rich_help_panel=RUN_PANEL)(commands.run_command)
|
|
105
|
+
app.command("backfill", rich_help_panel=RUN_PANEL)(commands.backfill_command)
|
|
106
|
+
app.command("init", rich_help_panel=DEFINE_PANEL)(commands.init_command)
|
|
107
|
+
app.command("apply", rich_help_panel=DEFINE_PANEL)(commands.apply_command)
|
|
108
|
+
app.command("validate", rich_help_panel=DEFINE_PANEL)(commands.validate_command)
|
|
109
|
+
app.command("export", rich_help_panel=DEFINE_PANEL)(commands.export_command)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.callback()
|
|
113
|
+
def main_callback(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
url: Annotated[str | None, typer.Option("--url", help="The server to talk to.")] = None,
|
|
116
|
+
token: Annotated[str | None, typer.Option("--token", help="The bearer token to present.")] = None,
|
|
117
|
+
profile: Annotated[str | None, typer.Option("--profile", help="Which profile to use.")] = None,
|
|
118
|
+
verbose: Annotated[
|
|
119
|
+
int,
|
|
120
|
+
typer.Option("-v", "--verbose", count=True, help="Engine events and API calls, dirigent's own only."),
|
|
121
|
+
] = 0,
|
|
122
|
+
debug: Annotated[
|
|
123
|
+
bool,
|
|
124
|
+
typer.Option("--debug", "-d", help="Engine internals: claims, references, probes, leases."),
|
|
125
|
+
] = False,
|
|
126
|
+
debug_all: Annotated[
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option("--debug-all", help="Everything, including every library's own logging."),
|
|
129
|
+
] = False,
|
|
130
|
+
json_output: Annotated[
|
|
131
|
+
bool,
|
|
132
|
+
typer.Option("--json", help="Write JSON. The same as --output json."),
|
|
133
|
+
] = False,
|
|
134
|
+
output: Annotated[
|
|
135
|
+
str | None,
|
|
136
|
+
typer.Option("-o", "--output", help="Output to write: json (the default) or console."),
|
|
137
|
+
] = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Resolve which server the CLI talks to and how loud to be, before any command runs.
|
|
140
|
+
|
|
141
|
+
Precedence is flags, then DG_URL / DG_TOKEN / DIRIGENT_LOG_LEVEL, then the profile.
|
|
142
|
+
"""
|
|
143
|
+
ctx.obj = CliState(
|
|
144
|
+
url=url,
|
|
145
|
+
token=token,
|
|
146
|
+
profile=profile,
|
|
147
|
+
verbose=verbose,
|
|
148
|
+
debug=debug,
|
|
149
|
+
debug_all=debug_all,
|
|
150
|
+
output=_output_format(output, json_output=json_output),
|
|
151
|
+
chosen=output is not None or json_output or LOG_FORMAT_ENV in os.environ,
|
|
152
|
+
)
|
|
153
|
+
ctx.obj.configure_output()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _output_format(named: str | None, *, json_output: bool) -> Format:
|
|
157
|
+
"""Resolve the output: the flag, then the environment, then NDJSON.
|
|
158
|
+
|
|
159
|
+
``--json`` is the same request as ``--output json``, and the environment is where a
|
|
160
|
+
container says it once. NDJSON is what an unasked invocation writes, so what a command
|
|
161
|
+
emits does not depend on who happens to be reading it; ``-o console`` renders it.
|
|
162
|
+
"""
|
|
163
|
+
chosen = named or ("json" if json_output else None) or os.environ.get(LOG_FORMAT_ENV)
|
|
164
|
+
if chosen is None:
|
|
165
|
+
return "json"
|
|
166
|
+
resolved = chosen.lower()
|
|
167
|
+
if resolved not in FORMATS:
|
|
168
|
+
# The output that was asked for is exactly what is missing, so the refusal is written
|
|
169
|
+
# in the default one rather than in the one that was named.
|
|
170
|
+
emit_problem(
|
|
171
|
+
f"{chosen!r} is not an output format",
|
|
172
|
+
status=2,
|
|
173
|
+
title="Not an output format",
|
|
174
|
+
problems=[f"the outputs are {', '.join(FORMATS)}"],
|
|
175
|
+
)
|
|
176
|
+
raise typer.Exit(code=2)
|
|
177
|
+
return cast("Format", resolved)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
DISTRIBUTIONS = ("dirigent-cli", "dirigent-core", "dirigent-server", "dirigent-plugin", "dirigent-blocks")
|
|
181
|
+
|
|
182
|
+
DEV_ADMIN = "dev"
|
|
183
|
+
DEV_PASSWORD = "dirigent-dev" # noqa: S105
|
|
184
|
+
DEV_TOKEN_NAME = "dev"
|
|
185
|
+
|
|
186
|
+
SCHEDULER_ENV = "DIRIGENT_SCHEDULER_ENABLED"
|
|
187
|
+
|
|
188
|
+
UI_ENV = "DIRIGENT_UI_ENABLED"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def distribution_version(name: str) -> str:
|
|
192
|
+
"""Read an installed distribution's version, or report that it is absent."""
|
|
193
|
+
try:
|
|
194
|
+
return version(name)
|
|
195
|
+
except PackageNotFoundError:
|
|
196
|
+
return "not installed"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command(name="format", rich_help_panel=RUN_PANEL)
|
|
200
|
+
def format_command(
|
|
201
|
+
formatter: Annotated[
|
|
202
|
+
str | None,
|
|
203
|
+
typer.Argument(help=f"Which formatter renders the records. One of: {', '.join(names())}."),
|
|
204
|
+
] = None,
|
|
205
|
+
file: Annotated[
|
|
206
|
+
Path | None,
|
|
207
|
+
typer.Option("-f", "--file", help="A file holding one record per line; standard input when omitted."),
|
|
208
|
+
] = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Render an NDJSON stream for reading, from a pipe or from a file.
|
|
211
|
+
|
|
212
|
+
A line that is not a record is passed through untouched, so a mixed log still reads.
|
|
213
|
+
"""
|
|
214
|
+
from dirigent_cli.formatters import looks_like_a_template, registry, rendered
|
|
215
|
+
|
|
216
|
+
# Rendering is what this command is. Every other command writes NDJSON unless asked
|
|
217
|
+
# otherwise, and inheriting that default here would mute the console doing the work.
|
|
218
|
+
configure(output="console", detail=detail_mode())
|
|
219
|
+
registered = registry()
|
|
220
|
+
named = DEFAULT if formatter is None else formatter
|
|
221
|
+
if named not in registered:
|
|
222
|
+
if looks_like_a_template(named):
|
|
223
|
+
# docker and kubectl take a template here, so the habit is worth answering rather
|
|
224
|
+
# than refusing. Picking fields out of a record is what jq does, over this stream.
|
|
225
|
+
emit_problem(
|
|
226
|
+
"a formatter renders whole records; it takes no template",
|
|
227
|
+
status=2,
|
|
228
|
+
title="Not a formatter",
|
|
229
|
+
problems=["to pick fields out, use jq: dg dev | jq -r '.step'"],
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
# The formatter that would render this refusal is the one that is missing, so it
|
|
233
|
+
# is written in the default output rather than rendered.
|
|
234
|
+
emit_problem(
|
|
235
|
+
f"{named!r} is not a formatter",
|
|
236
|
+
status=2,
|
|
237
|
+
title="Not a formatter",
|
|
238
|
+
problems=[f"the formatters are {', '.join(registered)}"],
|
|
239
|
+
)
|
|
240
|
+
raise typer.Exit(code=2)
|
|
241
|
+
lines = file.read_text().splitlines() if file is not None else sys.stdin
|
|
242
|
+
for item in rendered(lines, registered[named]):
|
|
243
|
+
emit_rendered(item)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.command(name="version", rich_help_panel=ADMIN_PANEL)
|
|
247
|
+
def version_command() -> None:
|
|
248
|
+
"""Show the versions of the installed dirigent packages."""
|
|
249
|
+
emit_fact(
|
|
250
|
+
"version",
|
|
251
|
+
message="dirigent",
|
|
252
|
+
packages=[{"package": name, "version": distribution_version(name)} for name in DISTRIBUTIONS],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@config_app.command("show")
|
|
257
|
+
def config_show() -> None:
|
|
258
|
+
"""Show the effective configuration, with secrets redacted."""
|
|
259
|
+
settings = get_settings()
|
|
260
|
+
emit_fact(
|
|
261
|
+
"config",
|
|
262
|
+
message="effective configuration",
|
|
263
|
+
settings={
|
|
264
|
+
name: ("***" if name == "secret_key" and value is not None else str(value))
|
|
265
|
+
for name, value in settings.model_dump().items()
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@db_app.command("upgrade")
|
|
271
|
+
def db_upgrade(
|
|
272
|
+
revision: Annotated[str, typer.Argument(help="The revision to reach; the newest when omitted.")] = "head",
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Bring the database schema up to a revision, creating it when empty."""
|
|
275
|
+
settings = get_settings()
|
|
276
|
+
migrations.upgrade(revision, settings)
|
|
277
|
+
emit_fact(
|
|
278
|
+
"db.upgraded",
|
|
279
|
+
message="upgraded",
|
|
280
|
+
database=redacted_url(settings),
|
|
281
|
+
target=revision,
|
|
282
|
+
revision=migrations.current_revision(settings),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@db_app.command("current")
|
|
287
|
+
def db_current() -> None:
|
|
288
|
+
"""Show the revision the database is stamped with."""
|
|
289
|
+
settings = get_settings()
|
|
290
|
+
current = migrations.current_revision(settings)
|
|
291
|
+
head = migrations.head_revision(settings)
|
|
292
|
+
if current is None:
|
|
293
|
+
refuse("the database has never been migrated", title="Not migrated", problems=["run dg db upgrade"])
|
|
294
|
+
raise typer.Exit(code=1)
|
|
295
|
+
emit_fact(
|
|
296
|
+
"db.revision",
|
|
297
|
+
message="at head" if current == head else "behind head",
|
|
298
|
+
revision=current,
|
|
299
|
+
head=head,
|
|
300
|
+
at_head=current == head,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@db_app.command("history")
|
|
305
|
+
def db_history() -> None:
|
|
306
|
+
"""Show the migration history."""
|
|
307
|
+
emit_fact("db.history", message="migration history", history=migrations.history(get_settings()).rstrip())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@commands.connection_app.command("ensure")
|
|
311
|
+
def connection_ensure(
|
|
312
|
+
kind_id: Annotated[str, typer.Argument(metavar="KIND", help="The connection kind, such as s3.")],
|
|
313
|
+
code: Annotated[str, typer.Argument(help="What to call it; documents reference this code.")],
|
|
314
|
+
name: Annotated[str | None, typer.Option(help="A human title for this connection.")] = None,
|
|
315
|
+
set_value: Annotated[list[str] | None, typer.Option("--set", help="field=value, repeatable.")] = None,
|
|
316
|
+
description: Annotated[str | None, typer.Option(help="What this credential is for.")] = None,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Write a connection straight to the database, creating it or bringing it to this config.
|
|
319
|
+
|
|
320
|
+
Process-side the way ``dg db upgrade`` is: it reads the database URL and the instance key
|
|
321
|
+
rather than a token, so a one-shot container can put a credential in place before
|
|
322
|
+
anything else starts. The config is validated against its kind and its secret half sealed
|
|
323
|
+
with the instance key exactly as the API does it, so the row is indistinguishable from one
|
|
324
|
+
made through ``dg connection create``.
|
|
325
|
+
|
|
326
|
+
The row is brought to what the arguments say, whole: a field left out is cleared.
|
|
327
|
+
"""
|
|
328
|
+
import asyncio
|
|
329
|
+
|
|
330
|
+
from dirigent_core.plugins import load_plugin_host
|
|
331
|
+
from dirigent_core.secrets import REDACTED, SecretBox, SecretError, redact, secret_fields
|
|
332
|
+
|
|
333
|
+
settings = get_settings()
|
|
334
|
+
kinds = load_plugin_host().connection_kinds
|
|
335
|
+
kind = kinds.get(kind_id)
|
|
336
|
+
if kind is None:
|
|
337
|
+
known = ", ".join(sorted(kinds)) or "none are installed"
|
|
338
|
+
commands.fail(f"no connection kind {kind_id!r} is installed ({known})")
|
|
339
|
+
model = kind.config_model
|
|
340
|
+
config = commands.parse_params(set_value, schema=model.model_json_schema())
|
|
341
|
+
try:
|
|
342
|
+
validated = model.model_validate(config)
|
|
343
|
+
except ValidationError as error:
|
|
344
|
+
# include_input=False: the input here is a credential, and pydantic's default error
|
|
345
|
+
# payload echoes the value that failed.
|
|
346
|
+
commands.fail(f"{code} is not a usable {kind_id} connection: {error.errors(include_input=False)}")
|
|
347
|
+
key = settings.secret_key.get_secret_value() if settings.secret_key else None
|
|
348
|
+
try:
|
|
349
|
+
public, envelope, key_id = SecretBox(key).encrypt_config(model, validated)
|
|
350
|
+
except SecretError as error:
|
|
351
|
+
commands.fail(str(error))
|
|
352
|
+
written = asyncio.run(
|
|
353
|
+
store_connection(
|
|
354
|
+
settings,
|
|
355
|
+
code=code,
|
|
356
|
+
kind=kind_id,
|
|
357
|
+
name=name,
|
|
358
|
+
description=description,
|
|
359
|
+
config=public,
|
|
360
|
+
envelope=envelope,
|
|
361
|
+
key_id=key_id,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
# A sealed field is not on the row at all, so the marker is put back: the record says the
|
|
365
|
+
# credential is set without saying what it is.
|
|
366
|
+
visible = redact(model, dict(public))
|
|
367
|
+
for field in secret_fields(model):
|
|
368
|
+
visible.setdefault(field, REDACTED if envelope is not None else None)
|
|
369
|
+
emit_fact(
|
|
370
|
+
f"connection.{written}",
|
|
371
|
+
message=written,
|
|
372
|
+
code=code,
|
|
373
|
+
connection_kind=kind_id,
|
|
374
|
+
name=name,
|
|
375
|
+
description=description,
|
|
376
|
+
config=visible,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def store_connection(
|
|
381
|
+
settings: Settings,
|
|
382
|
+
*,
|
|
383
|
+
code: str,
|
|
384
|
+
kind: str,
|
|
385
|
+
name: str | None,
|
|
386
|
+
description: str | None,
|
|
387
|
+
config: "JsonMap",
|
|
388
|
+
envelope: bytes | None,
|
|
389
|
+
key_id: str | None,
|
|
390
|
+
) -> str:
|
|
391
|
+
"""Insert or overwrite one connection row, and say which of the two happened."""
|
|
392
|
+
import sqlalchemy as sa
|
|
393
|
+
|
|
394
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
395
|
+
from dirigent_core.models import Connection
|
|
396
|
+
|
|
397
|
+
engine = create_engine(settings)
|
|
398
|
+
try:
|
|
399
|
+
async with session_scope(create_session_factory(engine)) as session:
|
|
400
|
+
found = await session.execute(sa.select(Connection).where(Connection.code == code))
|
|
401
|
+
row = found.scalar_one_or_none()
|
|
402
|
+
written = "updated" if row is not None else "created"
|
|
403
|
+
if row is None:
|
|
404
|
+
row = Connection(code=code)
|
|
405
|
+
session.add(row)
|
|
406
|
+
row.kind = kind
|
|
407
|
+
row.name = name
|
|
408
|
+
row.description = description
|
|
409
|
+
row.config = config
|
|
410
|
+
row.secret_envelope = envelope
|
|
411
|
+
row.secret_key_id = key_id
|
|
412
|
+
return written
|
|
413
|
+
finally:
|
|
414
|
+
await engine.dispose()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@health_app.callback(invoke_without_command=True)
|
|
418
|
+
def health_here(ctx: typer.Context) -> None:
|
|
419
|
+
"""Check the instance this shell resolves: database, workers, schedules, the server.
|
|
420
|
+
|
|
421
|
+
A part that is simply not there is reported and does not fail the command; naming one
|
|
422
|
+
is what asserts it should be running.
|
|
423
|
+
"""
|
|
424
|
+
if ctx.invoked_subcommand is not None:
|
|
425
|
+
return
|
|
426
|
+
from dirigent_cli.health import every_check, named_server
|
|
427
|
+
|
|
428
|
+
state = state_of(ctx)
|
|
429
|
+
server = named_server(url=state.url, profile=state.profile)
|
|
430
|
+
_checked(every_check(get_settings(), server=server), asserted=False, summarise=True)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@health_app.command("worker")
|
|
434
|
+
def health_worker() -> None:
|
|
435
|
+
"""Say whether a worker on this host is still heartbeating, and exit 0 or 1."""
|
|
436
|
+
from dirigent_cli.health import worker_health
|
|
437
|
+
|
|
438
|
+
_checked([worker_health(get_settings())], asserted=True)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@health_app.command("server")
|
|
442
|
+
def health_server(
|
|
443
|
+
ctx: typer.Context,
|
|
444
|
+
liveness: Annotated[
|
|
445
|
+
bool, typer.Option("--liveness", help="Ask whether the process answers at all, not whether it is ready.")
|
|
446
|
+
] = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Ask the server -- the named one, or this host's own -- for its readiness, and exit 0 or 1."""
|
|
449
|
+
from dirigent_cli.health import named_server, server_check
|
|
450
|
+
|
|
451
|
+
state = state_of(ctx)
|
|
452
|
+
server = named_server(url=state.url, profile=state.profile)
|
|
453
|
+
check = server_check(get_settings(), server=server, probe="liveness" if liveness else "readiness")
|
|
454
|
+
_checked([check], asserted=True)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@health_app.command("scheduler")
|
|
458
|
+
def health_scheduler() -> None:
|
|
459
|
+
"""Say whether the schedules that should have fired have fired, and exit 0 or 1."""
|
|
460
|
+
from dirigent_cli.health import scheduler_health
|
|
461
|
+
|
|
462
|
+
_checked([scheduler_health(get_settings())], asserted=True)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@health_app.command("database")
|
|
466
|
+
def health_database() -> None:
|
|
467
|
+
"""Say whether the configured database answers, and exit 0 or 1."""
|
|
468
|
+
from dirigent_cli.health import database_health
|
|
469
|
+
|
|
470
|
+
_checked([database_health(get_settings())], asserted=True)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _checked(checks: "Sequence[Check]", *, asserted: bool, summarise: bool = False) -> None:
|
|
474
|
+
"""Write one record per check, then the verdict, and exit 1 if any of them failed."""
|
|
475
|
+
from dirigent_cli.output import output_mode
|
|
476
|
+
from dirigent_cli.stream import Sink
|
|
477
|
+
|
|
478
|
+
sink = Sink(output_mode())
|
|
479
|
+
failed = False
|
|
480
|
+
for check in checks:
|
|
481
|
+
failed = failed or check.failed(asserted=asserted)
|
|
482
|
+
sink.event(
|
|
483
|
+
"check",
|
|
484
|
+
level="error" if check.failed(asserted=asserted) else "info",
|
|
485
|
+
message=check.detail,
|
|
486
|
+
check=check.check,
|
|
487
|
+
status=check.status,
|
|
488
|
+
probe=check.probe,
|
|
489
|
+
)
|
|
490
|
+
if summarise:
|
|
491
|
+
from dirigent_cli.health import verdict
|
|
492
|
+
|
|
493
|
+
sink.event("health", **verdict(checks))
|
|
494
|
+
if failed:
|
|
495
|
+
raise typer.Exit(code=1)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _level(ctx: typer.Context, settings: Settings) -> str:
|
|
499
|
+
"""Choose the level a process entry point runs at: -v first, then its own settings."""
|
|
500
|
+
state = ctx.find_object(CliState)
|
|
501
|
+
return state.level if state is not None and state.verbose else settings.log_level
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _cap_foreign(ctx: typer.Context) -> bool:
|
|
505
|
+
"""Report whether a raised verbosity should stay pointed at dirigent's own loggers.
|
|
506
|
+
|
|
507
|
+
A process at its configured level logs whatever it is configured to, uvicorn's access
|
|
508
|
+
lines included; a person who asked for more detail asked for dirigent's, and only
|
|
509
|
+
--debug-all asks for everybody's.
|
|
510
|
+
"""
|
|
511
|
+
state = ctx.find_object(CliState)
|
|
512
|
+
if state is None or state.debug_all:
|
|
513
|
+
return False
|
|
514
|
+
return bool(state.verbose or state.debug)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@app.command(name="prune", rich_help_panel=ADMIN_PANEL)
|
|
518
|
+
def prune_command(
|
|
519
|
+
ctx: typer.Context,
|
|
520
|
+
runs: Annotated[str | None, typer.Option(help="Keep settled runs younger than this, such as 30d.")] = None,
|
|
521
|
+
logs: Annotated[str | None, typer.Option(help="Keep log entries younger than this.")] = None,
|
|
522
|
+
deliveries: Annotated[str | None, typer.Option(help="Keep webhook deliveries younger than this.")] = None,
|
|
523
|
+
firings: Annotated[str | None, typer.Option(help="Keep schedule firings younger than this.")] = None,
|
|
524
|
+
notifications: Annotated[str | None, typer.Option(help="Keep sent alerts younger than this.")] = None,
|
|
525
|
+
scratch: Annotated[
|
|
526
|
+
bool,
|
|
527
|
+
typer.Option("--scratch/--no-scratch", help="Delete a pruned run's artifacts from storage too."),
|
|
528
|
+
] = True,
|
|
529
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Report what would go without deleting it.")] = False,
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Delete what the retention ages have outlived, against this host's database.
|
|
532
|
+
|
|
533
|
+
An age given here beats the configured one, and a family with neither is not pruned:
|
|
534
|
+
a prune deletes what it was asked to delete and never guesses at an age.
|
|
535
|
+
"""
|
|
536
|
+
import asyncio
|
|
537
|
+
|
|
538
|
+
settings = get_settings()
|
|
539
|
+
state = state_of(ctx)
|
|
540
|
+
try:
|
|
541
|
+
policy = _prune_policy(settings, runs, logs, deliveries, firings, notifications, scratch=scratch)
|
|
542
|
+
except ValueError as error:
|
|
543
|
+
refuse(str(error), status=2, title="Invalid age")
|
|
544
|
+
raise typer.Exit(code=2) from error
|
|
545
|
+
asyncio.run(_prune(settings, policy, state, dry_run=dry_run))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _prune_policy(
|
|
549
|
+
settings: Settings,
|
|
550
|
+
runs: str | None,
|
|
551
|
+
logs: str | None,
|
|
552
|
+
deliveries: str | None,
|
|
553
|
+
firings: str | None,
|
|
554
|
+
notifications: str | None,
|
|
555
|
+
*,
|
|
556
|
+
scratch: bool,
|
|
557
|
+
) -> "retention.Policy":
|
|
558
|
+
"""Resolve each family's age: the flag, then the setting, then not pruned at all."""
|
|
559
|
+
from dirigent_common import parse_duration
|
|
560
|
+
from dirigent_core import retention
|
|
561
|
+
|
|
562
|
+
def age(given: str | None, configured: timedelta | None) -> timedelta | None:
|
|
563
|
+
return cast("timedelta", parse_duration(given)) if given is not None else configured
|
|
564
|
+
|
|
565
|
+
return retention.Policy(
|
|
566
|
+
runs=age(runs, settings.retention_runs),
|
|
567
|
+
logs=age(logs, settings.retention_logs),
|
|
568
|
+
deliveries=age(deliveries, settings.retention_deliveries),
|
|
569
|
+
firings=age(firings, settings.retention_firings),
|
|
570
|
+
notifications=age(notifications, settings.retention_notifications),
|
|
571
|
+
scratch=scratch,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
async def _prune(settings: Settings, policy: "retention.Policy", state: CliState, *, dry_run: bool) -> None:
|
|
576
|
+
"""Sweep directly against the configured database, and write what went."""
|
|
577
|
+
from dirigent_core import retention
|
|
578
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
579
|
+
from dirigent_core.engine.services import EngineServices
|
|
580
|
+
from dirigent_core.plugins import load_plugin_host
|
|
581
|
+
from dirigent_core.scheduler import prune as prune_all
|
|
582
|
+
|
|
583
|
+
out = commands.sink(state)
|
|
584
|
+
if not retention.configured(policy):
|
|
585
|
+
refuse(
|
|
586
|
+
"no family has an age, so there is nothing to prune",
|
|
587
|
+
status=2,
|
|
588
|
+
title="Nothing configured",
|
|
589
|
+
problems=["give --runs, --logs, --deliveries, --firings or --notifications, or configure one"],
|
|
590
|
+
)
|
|
591
|
+
raise typer.Exit(code=2)
|
|
592
|
+
engine = create_engine(settings)
|
|
593
|
+
try:
|
|
594
|
+
sessions = create_session_factory(engine)
|
|
595
|
+
services = EngineServices.build(settings, load_plugin_host())
|
|
596
|
+
if dry_run:
|
|
597
|
+
async with session_scope(sessions) as session:
|
|
598
|
+
counts = await retention.counted(session, policy)
|
|
599
|
+
out.event("prune", message="would prune", dry_run=True, total=sum(counts.values()), **counts)
|
|
600
|
+
return
|
|
601
|
+
swept = await prune_all(sessions, services, policy)
|
|
602
|
+
out.event(
|
|
603
|
+
"prune",
|
|
604
|
+
message="pruned",
|
|
605
|
+
dry_run=False,
|
|
606
|
+
total=swept.total,
|
|
607
|
+
artifacts=swept.scratch_deleted,
|
|
608
|
+
unreachable=swept.scratch_failed or None,
|
|
609
|
+
**swept.counts,
|
|
610
|
+
)
|
|
611
|
+
finally:
|
|
612
|
+
await engine.dispose()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@app.command(rich_help_panel=PROCESS_PANEL)
|
|
616
|
+
def server(
|
|
617
|
+
ctx: typer.Context,
|
|
618
|
+
host: Annotated[str | None, typer.Option(help="Address to bind.")] = None,
|
|
619
|
+
port: Annotated[int | None, typer.Option(help="Port to bind.")] = None,
|
|
620
|
+
reload: Annotated[bool, typer.Option(help="Reload on source changes.")] = False,
|
|
621
|
+
scheduler: Annotated[
|
|
622
|
+
bool,
|
|
623
|
+
typer.Option("--scheduler/--no-scheduler", help="Embed the scheduler, or leave it to its own process."),
|
|
624
|
+
] = True,
|
|
625
|
+
ui: Annotated[
|
|
626
|
+
bool | None,
|
|
627
|
+
typer.Option(
|
|
628
|
+
"--ui/--no-ui", help="Serve the web UI, or run as an API only; the ui_enabled setting decides when omitted."
|
|
629
|
+
),
|
|
630
|
+
] = None,
|
|
631
|
+
) -> None:
|
|
632
|
+
"""Run the API server, with the scheduler embedded unless it is isolated."""
|
|
633
|
+
import os
|
|
634
|
+
|
|
635
|
+
import uvicorn
|
|
636
|
+
|
|
637
|
+
if not scheduler:
|
|
638
|
+
# Must travel through the environment: uvicorn builds the app in a reloader
|
|
639
|
+
# subprocess, where an argument to this function would not survive.
|
|
640
|
+
os.environ[SCHEDULER_ENV] = "false"
|
|
641
|
+
reset_settings_cache()
|
|
642
|
+
if ui is not None:
|
|
643
|
+
# The same subprocess constraint as the scheduler flag.
|
|
644
|
+
os.environ[UI_ENV] = "true" if ui else "false"
|
|
645
|
+
reset_settings_cache()
|
|
646
|
+
settings = get_settings()
|
|
647
|
+
configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
|
|
648
|
+
if settings.is_sqlite and settings.scheduler_enabled:
|
|
649
|
+
refuse(
|
|
650
|
+
"dg server embeds the scheduler, and leadership is a PostgreSQL advisory lock: on SQLite "
|
|
651
|
+
"nothing stops a second server double-firing every schedule",
|
|
652
|
+
status=commands.GUARD_EXIT,
|
|
653
|
+
title="SQLite cannot elect a leader",
|
|
654
|
+
problems=[
|
|
655
|
+
"use dg dev for the standalone mode",
|
|
656
|
+
"or pass --no-scheduler",
|
|
657
|
+
"or point DIRIGENT_DATABASE_URL at PostgreSQL",
|
|
658
|
+
],
|
|
659
|
+
)
|
|
660
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
661
|
+
uvicorn.run(
|
|
662
|
+
"dirigent_cli.main:build_app",
|
|
663
|
+
factory=True,
|
|
664
|
+
host=host or settings.host,
|
|
665
|
+
port=port or settings.port,
|
|
666
|
+
reload=reload,
|
|
667
|
+
log_level=settings.log_level.lower(),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@docker_app.command("reap")
|
|
672
|
+
def docker_reap(
|
|
673
|
+
ctx: typer.Context,
|
|
674
|
+
dry_run: Annotated[
|
|
675
|
+
bool,
|
|
676
|
+
typer.Option("--dry-run", help="Say what would be taken down, and take nothing down."),
|
|
677
|
+
] = False,
|
|
678
|
+
) -> None:
|
|
679
|
+
"""Take down compose stacks this host's daemon still holds for runs that have ended.
|
|
680
|
+
|
|
681
|
+
The same pass a worker runs on `docker_reap_interval`, on demand. It sees only the daemon
|
|
682
|
+
this host's environment names, which with one daemon per worker is that worker's own stacks.
|
|
683
|
+
"""
|
|
684
|
+
import asyncio
|
|
685
|
+
|
|
686
|
+
from dirigent_cli import reaper
|
|
687
|
+
from dirigent_cli.output import output_mode
|
|
688
|
+
from dirigent_cli.stream import Sink
|
|
689
|
+
|
|
690
|
+
settings = get_settings()
|
|
691
|
+
configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stderr)
|
|
692
|
+
if not reaper.reachable():
|
|
693
|
+
refuse(
|
|
694
|
+
"docker is not on this host's PATH, so there is no daemon to reap stacks from",
|
|
695
|
+
status=commands.GUARD_EXIT,
|
|
696
|
+
title="No docker daemon",
|
|
697
|
+
)
|
|
698
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
699
|
+
asyncio.run(_docker_reap(settings, Sink(output_mode()), dry_run=dry_run))
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
async def _docker_reap(settings: Settings, sink: "Sink", *, dry_run: bool) -> None:
|
|
703
|
+
"""Run one reaping pass and write a record per project it acted on."""
|
|
704
|
+
from dirigent_cli import reaper
|
|
705
|
+
from dirigent_core.database import create_engine, create_session_factory
|
|
706
|
+
|
|
707
|
+
engine = create_engine(settings)
|
|
708
|
+
try:
|
|
709
|
+
reaped = await reaper.pass_once(settings, create_session_factory(engine), dry_run=dry_run)
|
|
710
|
+
finally:
|
|
711
|
+
await engine.dispose()
|
|
712
|
+
for one in reaped:
|
|
713
|
+
sink.event("docker_reaped", message=reaper.outcome(one, dry_run=dry_run), **reaper.record(one))
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def build_app() -> "FastAPI":
|
|
717
|
+
"""Build the ASGI application; uvicorn calls this as its factory."""
|
|
718
|
+
from dirigent_server import create_app
|
|
719
|
+
|
|
720
|
+
return create_app(get_settings())
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def build_worker(
|
|
724
|
+
settings: Settings,
|
|
725
|
+
*,
|
|
726
|
+
concurrency: int | None = None,
|
|
727
|
+
tags: list[str] | None = None,
|
|
728
|
+
name: str | None = None,
|
|
729
|
+
) -> tuple["Worker", "AsyncEngine"]:
|
|
730
|
+
"""Assemble a worker from the installed plugins and the configured database."""
|
|
731
|
+
from dirigent_cli import reaper
|
|
732
|
+
from dirigent_core.database import create_engine, create_session_factory
|
|
733
|
+
from dirigent_core.engine import EngineServices
|
|
734
|
+
from dirigent_core.plugins import load_plugin_host
|
|
735
|
+
from dirigent_core.worker import Worker
|
|
736
|
+
|
|
737
|
+
services = EngineServices.build(settings, load_plugin_host())
|
|
738
|
+
engine = create_engine(settings)
|
|
739
|
+
sessions = create_session_factory(engine)
|
|
740
|
+
reaping = reaper.chore(settings, sessions)
|
|
741
|
+
worker = Worker(
|
|
742
|
+
sessions,
|
|
743
|
+
services,
|
|
744
|
+
name=name,
|
|
745
|
+
concurrency=concurrency,
|
|
746
|
+
tags=tags,
|
|
747
|
+
chores=[reaping] if reaping is not None else [],
|
|
748
|
+
)
|
|
749
|
+
return worker, engine
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@commands.user_app.command("create")
|
|
753
|
+
def user_create(
|
|
754
|
+
username: Annotated[str, typer.Argument(help="The account to create.")],
|
|
755
|
+
role: Annotated[UserRole, typer.Option("--role", help="What the account may do.")],
|
|
756
|
+
password: Annotated[str | None, typer.Option(help="Its password; prompted for when omitted.")] = None,
|
|
757
|
+
email: Annotated[str | None, typer.Option(help="Its email address, which is unique across accounts.")] = None,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Create a local account against this host's database, which is the first-run path.
|
|
760
|
+
|
|
761
|
+
The role is named on every account; `DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD` creates the
|
|
762
|
+
first admin unattended.
|
|
763
|
+
"""
|
|
764
|
+
import asyncio
|
|
765
|
+
|
|
766
|
+
settings = get_settings()
|
|
767
|
+
secret = password or typer.prompt("password", hide_input=True, confirmation_prompt=True)
|
|
768
|
+
created = asyncio.run(_create_user(settings, username, secret, role, email))
|
|
769
|
+
emit_fact(
|
|
770
|
+
"user.created",
|
|
771
|
+
message="created",
|
|
772
|
+
username=created,
|
|
773
|
+
role=role.value,
|
|
774
|
+
database=redacted_url(settings),
|
|
775
|
+
hint="log in with dg auth login, or mint a token with dg admin token create",
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
async def _create_user(
|
|
780
|
+
settings: Settings, username: str, password: str, role: UserRole, email: str | None = None
|
|
781
|
+
) -> str:
|
|
782
|
+
"""Create one account directly against the configured database."""
|
|
783
|
+
from dirigent_core.auth import AuthError, create_user
|
|
784
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
785
|
+
|
|
786
|
+
engine = create_engine(settings)
|
|
787
|
+
try:
|
|
788
|
+
async with session_scope(create_session_factory(engine)) as session:
|
|
789
|
+
user = await create_user(session, username, password, role=role, email=email)
|
|
790
|
+
return user.username
|
|
791
|
+
except AuthError as error:
|
|
792
|
+
refuse(str(error))
|
|
793
|
+
raise typer.Exit(code=1) from error
|
|
794
|
+
finally:
|
|
795
|
+
await engine.dispose()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@app.command(rich_help_panel=PROCESS_PANEL)
|
|
799
|
+
def dev(
|
|
800
|
+
ctx: typer.Context,
|
|
801
|
+
host: Annotated[str | None, typer.Option(help="Address to bind.")] = None,
|
|
802
|
+
port: Annotated[int | None, typer.Option(help="Port to bind.")] = None,
|
|
803
|
+
ui: Annotated[
|
|
804
|
+
bool | None,
|
|
805
|
+
typer.Option(
|
|
806
|
+
"--ui/--no-ui", help="Serve the web UI, or run as an API only; the ui_enabled setting decides when omitted."
|
|
807
|
+
),
|
|
808
|
+
] = None,
|
|
809
|
+
wipe_state: Annotated[
|
|
810
|
+
bool,
|
|
811
|
+
typer.Option(
|
|
812
|
+
"--wipe-state/--keep-state",
|
|
813
|
+
help="Delete the state directory before starting, or keep what the last run left.",
|
|
814
|
+
),
|
|
815
|
+
] = True,
|
|
816
|
+
) -> None:
|
|
817
|
+
"""Run the API and an embedded worker in one process, on SQLite, with no dependencies.
|
|
818
|
+
|
|
819
|
+
The database and the artifacts live in .dirigent/state under the working directory, so
|
|
820
|
+
starting this somewhere else means a different instance, with none of the same runs.
|
|
821
|
+
|
|
822
|
+
That directory is deleted on every start unless --keep-state says otherwise: SQLite here
|
|
823
|
+
is a development artifact, and a database left over from an older schema answers
|
|
824
|
+
strangely rather than failing. Only a directory dirigent named itself is removed.
|
|
825
|
+
"""
|
|
826
|
+
import asyncio
|
|
827
|
+
import os
|
|
828
|
+
|
|
829
|
+
if ui is not None:
|
|
830
|
+
os.environ[UI_ENV] = "true" if ui else "false"
|
|
831
|
+
reset_settings_cache()
|
|
832
|
+
state = ctx.find_object(CliState)
|
|
833
|
+
settings = get_settings()
|
|
834
|
+
# A developer convenience starts its own logging at WARNING and -v is what asks for more,
|
|
835
|
+
# while a named DIRIGENT_LOG_LEVEL still wins.
|
|
836
|
+
configure_logging(
|
|
837
|
+
state.level if state is not None else "WARNING",
|
|
838
|
+
"json",
|
|
839
|
+
cap_foreign=state is None or not state.debug_all,
|
|
840
|
+
stream=sys.stdout,
|
|
841
|
+
)
|
|
842
|
+
if not settings.is_sqlite:
|
|
843
|
+
refuse(
|
|
844
|
+
"dg dev is the SQLite standalone mode",
|
|
845
|
+
status=commands.GUARD_EXIT,
|
|
846
|
+
title="Not SQLite",
|
|
847
|
+
problems=["use dg server on PostgreSQL"],
|
|
848
|
+
)
|
|
849
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
850
|
+
if wipe_state:
|
|
851
|
+
cleared = clear_state(settings)
|
|
852
|
+
if cleared is not None:
|
|
853
|
+
emit(state_cleared(cleared))
|
|
854
|
+
migrated = _migrate_quietly(settings)
|
|
855
|
+
admin, token = asyncio.run(dev_admin(settings))
|
|
856
|
+
bound = f"http://{host or settings.host}:{port or settings.port}"
|
|
857
|
+
emit(dev_started(settings, bound=bound, admin=admin, token=token, migrated=migrated))
|
|
858
|
+
asyncio.run(_dev(settings, host or settings.host, port or settings.port))
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def clear_state(settings: Settings) -> Path | None:
|
|
862
|
+
"""Delete the state directory this instance owns, naming it, or report that none went.
|
|
863
|
+
|
|
864
|
+
Only a directory spelled ``.dirigent/state`` is removed. A database configuration pointed
|
|
865
|
+
somewhere else belongs to whoever pointed it there -- a test fixture and the UI's
|
|
866
|
+
end-to-end harness both keep their database beside files they still need -- so the wipe
|
|
867
|
+
is refused rather than guessed at.
|
|
868
|
+
"""
|
|
869
|
+
database = settings.sqlite_path
|
|
870
|
+
if database is None:
|
|
871
|
+
return None
|
|
872
|
+
directory = database.parent.resolve()
|
|
873
|
+
if directory.parts[-2:] != Path(STATE_DIR).parts:
|
|
874
|
+
return None
|
|
875
|
+
if not directory.exists():
|
|
876
|
+
return None
|
|
877
|
+
shutil.rmtree(directory)
|
|
878
|
+
return directory
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def state_cleared(directory: Path) -> Record:
|
|
882
|
+
"""Build the record saying the state went, and how to have kept it.
|
|
883
|
+
|
|
884
|
+
Emitted only when something was actually deleted, so a first start on an empty machine
|
|
885
|
+
stays quiet.
|
|
886
|
+
"""
|
|
887
|
+
return make(
|
|
888
|
+
"process",
|
|
889
|
+
at=datetime.now(UTC),
|
|
890
|
+
message="state cleared",
|
|
891
|
+
process="dev",
|
|
892
|
+
state=str(directory),
|
|
893
|
+
hint="--keep-state keeps it",
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _migrate_quietly(settings: Settings) -> str | None:
|
|
898
|
+
"""Bring the schema up to date, and name the revision only when something moved."""
|
|
899
|
+
head = migrations.head_revision(settings)
|
|
900
|
+
if migrations.current_revision(settings) == head:
|
|
901
|
+
return None
|
|
902
|
+
migrations.upgrade("head", settings)
|
|
903
|
+
return head
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def dev_started(
|
|
907
|
+
settings: Settings, *, bound: str, admin: str | None, token: str | None, migrated: str | None
|
|
908
|
+
) -> Record:
|
|
909
|
+
"""Build the record carrying what a person needs to start working against a dev instance.
|
|
910
|
+
|
|
911
|
+
The token is minted once and never shown again, so it is a field on this record rather
|
|
912
|
+
than something only a rendering ever held.
|
|
913
|
+
"""
|
|
914
|
+
state = settings.sqlite_path
|
|
915
|
+
fields: dict[str, Any] = {"process": "dev", "api": bound, "docs": f"{bound}/docs", "admin": admin}
|
|
916
|
+
if state is not None:
|
|
917
|
+
fields["state"] = str(state.parent.resolve())
|
|
918
|
+
if token is not None:
|
|
919
|
+
fields["token"] = token
|
|
920
|
+
if migrated is not None:
|
|
921
|
+
fields["migrated"] = migrated
|
|
922
|
+
return make("process", at=datetime.now(UTC), message="starting", **fields)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def emit(record: Record) -> None:
|
|
926
|
+
"""Write one record to the stream and flush it: a reader is waiting on this line.
|
|
927
|
+
|
|
928
|
+
A reader that has gone ends the stream rather than the process: the pipe is closed and
|
|
929
|
+
every later write goes to the void, while shutdown runs to completion.
|
|
930
|
+
"""
|
|
931
|
+
try:
|
|
932
|
+
sys.stdout.write(f"{as_json(record)}\n")
|
|
933
|
+
sys.stdout.flush()
|
|
934
|
+
except BrokenPipeError:
|
|
935
|
+
silence_stdout()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
async def dev_admin(settings: Settings) -> tuple[str | None, str | None]:
|
|
939
|
+
"""Name the admin to log in as, and mint it a token where one is owed.
|
|
940
|
+
|
|
941
|
+
An empty instance gets the development admin made for it. One that already has accounts
|
|
942
|
+
-- ``dg init`` made it, or a person did -- keeps them: this names the admin that is
|
|
943
|
+
there rather than one that is not, and mints nothing, because that account's token was
|
|
944
|
+
handed over when it was created.
|
|
945
|
+
"""
|
|
946
|
+
from dirigent_core.auth import create_user, find_user, issue_token, list_tokens, list_users
|
|
947
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
948
|
+
|
|
949
|
+
engine = create_engine(settings)
|
|
950
|
+
try:
|
|
951
|
+
sessions = create_session_factory(engine)
|
|
952
|
+
async with session_scope(sessions) as session:
|
|
953
|
+
existing = await list_users(session)
|
|
954
|
+
if existing:
|
|
955
|
+
return (existing[0].username, None)
|
|
956
|
+
await create_user(session, DEV_ADMIN, DEV_PASSWORD, role=UserRole.ADMIN, name="Development admin")
|
|
957
|
+
user = await find_user(session, DEV_ADMIN)
|
|
958
|
+
if user is None: # pragma: no cover - it was just created
|
|
959
|
+
return (None, None)
|
|
960
|
+
if any(token.name == DEV_TOKEN_NAME and token.revoked_at is None for token in await list_tokens(session)):
|
|
961
|
+
return (DEV_ADMIN, None)
|
|
962
|
+
issued = await issue_token(session, user, name=DEV_TOKEN_NAME)
|
|
963
|
+
return (DEV_ADMIN, issued.secret.get_secret_value())
|
|
964
|
+
finally:
|
|
965
|
+
await engine.dispose()
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
async def _dev(settings: Settings, host: str, port: int) -> None:
|
|
969
|
+
"""Run the API, the scheduler, and a worker as tasks in one event loop.
|
|
970
|
+
|
|
971
|
+
The scheduler is started by the application's own lifespan, not here.
|
|
972
|
+
"""
|
|
973
|
+
import asyncio
|
|
974
|
+
|
|
975
|
+
import uvicorn
|
|
976
|
+
|
|
977
|
+
from dirigent_core.worker import install_signal_handlers
|
|
978
|
+
|
|
979
|
+
worker, engine = build_worker(settings, concurrency=None, tags=None, name=None)
|
|
980
|
+
install_signal_handlers(worker)
|
|
981
|
+
server_config = uvicorn.Config(build_app(), host=host, port=port, log_config=None)
|
|
982
|
+
api = uvicorn.Server(server_config)
|
|
983
|
+
worker_task = asyncio.create_task(worker.run())
|
|
984
|
+
ready = asyncio.create_task(_announce_ready(api))
|
|
985
|
+
try:
|
|
986
|
+
await api.serve()
|
|
987
|
+
finally:
|
|
988
|
+
ready.cancel()
|
|
989
|
+
worker.request_stop()
|
|
990
|
+
await worker_task
|
|
991
|
+
await engine.dispose()
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
async def _announce_ready(api: "uvicorn.Server") -> None:
|
|
995
|
+
"""Say ready once the port is actually accepting, and not a moment before."""
|
|
996
|
+
import asyncio
|
|
997
|
+
|
|
998
|
+
while not api.started:
|
|
999
|
+
await asyncio.sleep(0.05)
|
|
1000
|
+
emit(make("process", at=datetime.now(UTC), message="ready", process="dev"))
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
@app.command(rich_help_panel=PROCESS_PANEL)
|
|
1004
|
+
def worker(
|
|
1005
|
+
ctx: typer.Context,
|
|
1006
|
+
concurrency: Annotated[int | None, typer.Option(help="How many block calls run at a time.")] = None,
|
|
1007
|
+
tag: Annotated[list[str] | None, typer.Option(help="Capability tag this worker advertises.")] = None,
|
|
1008
|
+
name: Annotated[str | None, typer.Option(help="Registry name; defaults to hostname and pid.")] = None,
|
|
1009
|
+
) -> None:
|
|
1010
|
+
"""Run a worker: claim due work, execute it, probe what is waiting, and drain on SIGTERM."""
|
|
1011
|
+
import asyncio
|
|
1012
|
+
|
|
1013
|
+
settings = get_settings()
|
|
1014
|
+
configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
|
|
1015
|
+
configure_telemetry(settings)
|
|
1016
|
+
if settings.is_sqlite:
|
|
1017
|
+
refuse(
|
|
1018
|
+
"dg worker refuses to start on SQLite: its claim fallback is only correct with exactly one process",
|
|
1019
|
+
status=commands.GUARD_EXIT,
|
|
1020
|
+
title="SQLite claims only one process",
|
|
1021
|
+
problems=["use dg dev", "or point DIRIGENT_DATABASE_URL at PostgreSQL"],
|
|
1022
|
+
)
|
|
1023
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1024
|
+
asyncio.run(_worker(settings, concurrency, tag, name))
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
async def _worker(settings: Settings, concurrency: int | None, tags: list[str] | None, name: str | None) -> None:
|
|
1028
|
+
"""Run one worker until it is asked to drain."""
|
|
1029
|
+
from dirigent_core.worker import install_signal_handlers
|
|
1030
|
+
|
|
1031
|
+
worker, engine = build_worker(settings, concurrency=concurrency, tags=tags, name=name)
|
|
1032
|
+
install_signal_handlers(worker)
|
|
1033
|
+
try:
|
|
1034
|
+
await worker.run()
|
|
1035
|
+
finally:
|
|
1036
|
+
await engine.dispose()
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@app.command(name="scheduler", rich_help_panel=PROCESS_PANEL)
|
|
1040
|
+
def scheduler_command(ctx: typer.Context) -> None:
|
|
1041
|
+
"""Run the scheduler on its own: take leadership, then turn clock time into runs.
|
|
1042
|
+
|
|
1043
|
+
Leadership is an advisory lock, so starting this beside a server that still embeds a
|
|
1044
|
+
scheduler is harmless: the second one stands by.
|
|
1045
|
+
"""
|
|
1046
|
+
import asyncio
|
|
1047
|
+
|
|
1048
|
+
settings = get_settings()
|
|
1049
|
+
configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
|
|
1050
|
+
configure_telemetry(settings)
|
|
1051
|
+
if settings.is_sqlite:
|
|
1052
|
+
refuse(
|
|
1053
|
+
"dg scheduler refuses to start on SQLite: leadership is a PostgreSQL advisory lock, and "
|
|
1054
|
+
"without one nothing stops a second scheduler double-firing every schedule",
|
|
1055
|
+
status=commands.GUARD_EXIT,
|
|
1056
|
+
title="SQLite cannot elect a leader",
|
|
1057
|
+
problems=["use dg dev", "or point DIRIGENT_DATABASE_URL at PostgreSQL"],
|
|
1058
|
+
)
|
|
1059
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1060
|
+
emit(
|
|
1061
|
+
make(
|
|
1062
|
+
"process",
|
|
1063
|
+
at=datetime.now(UTC),
|
|
1064
|
+
message="waiting for leadership",
|
|
1065
|
+
process="scheduler",
|
|
1066
|
+
database=redacted_url(settings),
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
asyncio.run(_scheduler(settings))
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
async def _scheduler(settings: Settings) -> None:
|
|
1073
|
+
"""Run one scheduler until it is asked to stop, releasing leadership on the way out."""
|
|
1074
|
+
from dirigent_core.database import create_engine, create_session_factory
|
|
1075
|
+
from dirigent_core.engine import EngineServices
|
|
1076
|
+
from dirigent_core.plugins import load_plugin_host
|
|
1077
|
+
from dirigent_core.scheduler import Scheduler, install_signal_handlers
|
|
1078
|
+
|
|
1079
|
+
services = EngineServices.build(settings, load_plugin_host())
|
|
1080
|
+
engine = create_engine(settings)
|
|
1081
|
+
clock = Scheduler(create_session_factory(engine), services)
|
|
1082
|
+
install_signal_handlers(clock)
|
|
1083
|
+
try:
|
|
1084
|
+
await clock.run()
|
|
1085
|
+
finally:
|
|
1086
|
+
await engine.dispose()
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
#: Options declared on the group, which a person expects to work in any position.
|
|
1090
|
+
GLOBAL_TOKENS = frozenset({"-v", "-vv", "-vvv", "--verbose", "-d", "--debug", "--debug-all", "--json", "--timestamps"})
|
|
1091
|
+
|
|
1092
|
+
#: The options declared on ``dg`` that take a value. Hoisting one moves its value with it.
|
|
1093
|
+
GLOBAL_VALUE_OPTIONS = frozenset({"--url", "--token", "--profile", "-o", "--output"})
|
|
1094
|
+
|
|
1095
|
+
# Options that consume the next token; a verbosity-looking token after one of these is a
|
|
1096
|
+
# value, not a flag. A new value-taking option must be added here.
|
|
1097
|
+
VALUE_OPTIONS = frozenset(
|
|
1098
|
+
{
|
|
1099
|
+
"--url",
|
|
1100
|
+
"--token",
|
|
1101
|
+
"--profile",
|
|
1102
|
+
"-p",
|
|
1103
|
+
"--param",
|
|
1104
|
+
"-P",
|
|
1105
|
+
"--params-file",
|
|
1106
|
+
"-o",
|
|
1107
|
+
"--output",
|
|
1108
|
+
"-f",
|
|
1109
|
+
"--file",
|
|
1110
|
+
"--as",
|
|
1111
|
+
"--cron",
|
|
1112
|
+
"--interval",
|
|
1113
|
+
"--at",
|
|
1114
|
+
"--tz",
|
|
1115
|
+
"--step",
|
|
1116
|
+
"--template",
|
|
1117
|
+
"--connections",
|
|
1118
|
+
"--concurrency",
|
|
1119
|
+
"--tag",
|
|
1120
|
+
"--event",
|
|
1121
|
+
"--notifier",
|
|
1122
|
+
"--status",
|
|
1123
|
+
"--pipeline",
|
|
1124
|
+
"--since",
|
|
1125
|
+
"--enable-unsafe",
|
|
1126
|
+
}
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def hoist_globals(argv: list[str]) -> list[str]:
|
|
1131
|
+
"""Move the options declared on ``dg`` in front of the subcommand, values and all.
|
|
1132
|
+
|
|
1133
|
+
Click reads a group's options only before the subcommand, so without this ``dg run
|
|
1134
|
+
--local doc.yaml -o json`` is refused for an option the help says is global. One that
|
|
1135
|
+
takes a value carries the next token with it, unless it was written as ``--url=...``.
|
|
1136
|
+
"""
|
|
1137
|
+
hoisted: list[str] = []
|
|
1138
|
+
rest: list[str] = []
|
|
1139
|
+
previous = ""
|
|
1140
|
+
wants_value = False
|
|
1141
|
+
for index, token in enumerate(argv):
|
|
1142
|
+
if token == "--":
|
|
1143
|
+
rest.extend(argv[index:])
|
|
1144
|
+
break
|
|
1145
|
+
if wants_value:
|
|
1146
|
+
hoisted.append(token)
|
|
1147
|
+
wants_value = False
|
|
1148
|
+
elif token.split("=", 1)[0] in GLOBAL_VALUE_OPTIONS and previous not in VALUE_OPTIONS:
|
|
1149
|
+
hoisted.append(token)
|
|
1150
|
+
wants_value = "=" not in token
|
|
1151
|
+
elif token in GLOBAL_TOKENS and previous not in VALUE_OPTIONS:
|
|
1152
|
+
hoisted.append(token)
|
|
1153
|
+
else:
|
|
1154
|
+
rest.append(token)
|
|
1155
|
+
previous = token
|
|
1156
|
+
return hoisted + rest
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
# `serve` is what mkdocs and jekyll call it, and there is no reason to make anyone stop and
|
|
1160
|
+
# think; `server` stays the name, so the three process commands keep naming a role.
|
|
1161
|
+
add_alias(app, "server", "serve")
|
|
1162
|
+
|
|
1163
|
+
# Must stay last: a command registered after this walk does not get an alias.
|
|
1164
|
+
LIST_ALIASES = add_list_aliases(app)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def run() -> None:
|
|
1168
|
+
"""Entry point for the `dirigent` and `dg` console scripts."""
|
|
1169
|
+
app(args=hoist_globals(sys.argv[1:]))
|