lorebase-cli 0.2.3__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.
- lorebase_cli/__init__.py +3 -0
- lorebase_cli/__main__.py +5 -0
- lorebase_cli/batch_import.py +330 -0
- lorebase_cli/coercion.py +176 -0
- lorebase_cli/commands/__init__.py +1 -0
- lorebase_cli/commands/admin.py +254 -0
- lorebase_cli/commands/agents.py +244 -0
- lorebase_cli/commands/audit.py +51 -0
- lorebase_cli/commands/data.py +742 -0
- lorebase_cli/commands/documents.py +161 -0
- lorebase_cli/commands/domains.py +404 -0
- lorebase_cli/commands/history.py +221 -0
- lorebase_cli/commands/proposals.py +476 -0
- lorebase_cli/commands/schema.py +523 -0
- lorebase_cli/commands/sources.py +69 -0
- lorebase_cli/commands/trace.py +140 -0
- lorebase_cli/context.py +93 -0
- lorebase_cli/failures.py +195 -0
- lorebase_cli/importing.py +277 -0
- lorebase_cli/linkml.py +1607 -0
- lorebase_cli/main.py +156 -0
- lorebase_cli/options.py +56 -0
- lorebase_cli/output.py +169 -0
- lorebase_cli/py.typed +0 -0
- lorebase_cli/settings.py +169 -0
- lorebase_cli/sources.py +118 -0
- lorebase_cli-0.2.3.dist-info/METADATA +11 -0
- lorebase_cli-0.2.3.dist-info/RECORD +30 -0
- lorebase_cli-0.2.3.dist-info/WHEEL +4 -0
- lorebase_cli-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""`apikey create|list|revoke`: API keys of a Domain; `admin erase`: erasure of personal data;
|
|
2
|
+
`admin vault status|rotate`: vault key rotation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from pydantic import JsonValue
|
|
13
|
+
|
|
14
|
+
from lorebase_cli.context import app_of
|
|
15
|
+
from lorebase_cli.failures import CliError
|
|
16
|
+
from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
|
|
17
|
+
from lorebase_cli.output import Column
|
|
18
|
+
|
|
19
|
+
apikey_app = typer.Typer(help="Manage the API keys of a Domain.", no_args_is_help=True)
|
|
20
|
+
admin_app = typer.Typer(help="Administer the personal data of a Domain.", no_args_is_help=True)
|
|
21
|
+
vault_app = typer.Typer(
|
|
22
|
+
help="Rotate the vault keys (installation administrators).", no_args_is_help=True
|
|
23
|
+
)
|
|
24
|
+
admin_app.add_typer(vault_app, name="vault")
|
|
25
|
+
|
|
26
|
+
KEY_COLUMNS = [
|
|
27
|
+
Column("id", "id"),
|
|
28
|
+
Column("prefix", "prefix"),
|
|
29
|
+
Column("principal", "principal_id"),
|
|
30
|
+
Column("expires", "expires_at"),
|
|
31
|
+
Column("last used", "last_used_at"),
|
|
32
|
+
Column("revoked", "revoked_at"),
|
|
33
|
+
Column("networks", "allowed_networks"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@apikey_app.command("create")
|
|
38
|
+
def apikey_create(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
name: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
typer.Argument(help="Name of the service principal the key belongs to.", metavar="NAME"),
|
|
43
|
+
],
|
|
44
|
+
domain: DomainOpt = None,
|
|
45
|
+
role: Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option("--role", help="Role the principal gets in the Domain (server default)."),
|
|
48
|
+
] = None,
|
|
49
|
+
ttl_days: Annotated[
|
|
50
|
+
int | None, typer.Option("--ttl-days", min=1, help="Lifetime in days (server default).")
|
|
51
|
+
] = None,
|
|
52
|
+
principal_id: Annotated[
|
|
53
|
+
str | None,
|
|
54
|
+
typer.Option("--principal-id", help="Add a key to an existing service principal."),
|
|
55
|
+
] = None,
|
|
56
|
+
allow_network: Annotated[
|
|
57
|
+
list[str] | None,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--allow-network",
|
|
60
|
+
metavar="CIDR",
|
|
61
|
+
help="Client network the key may be used from, e.g. 203.0.113.0/24; repeatable. "
|
|
62
|
+
"Without it the key works from any address.",
|
|
63
|
+
),
|
|
64
|
+
] = None,
|
|
65
|
+
json_out: JsonOpt = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Create an API key. The secret is shown once; store it right away."""
|
|
68
|
+
app = app_of(ctx, json_out)
|
|
69
|
+
created = app.client().api_keys.create(
|
|
70
|
+
app.domain(domain),
|
|
71
|
+
name,
|
|
72
|
+
role=role,
|
|
73
|
+
ttl_days=ttl_days,
|
|
74
|
+
principal_id=principal_id,
|
|
75
|
+
allowed_networks=allow_network,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def human() -> None:
|
|
79
|
+
app.out.pairs(
|
|
80
|
+
[
|
|
81
|
+
("key", created.key),
|
|
82
|
+
("id", created.id),
|
|
83
|
+
("principal", created.principal_id),
|
|
84
|
+
("role", created.role),
|
|
85
|
+
("expires", created.expires_at),
|
|
86
|
+
("networks", ", ".join(created.allowed_networks) or "any"),
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
app.out.info("This is the only time the key is shown: store it now.")
|
|
90
|
+
|
|
91
|
+
app.out.emit(created, human)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@apikey_app.command("list")
|
|
95
|
+
def apikey_list(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
domain: DomainOpt = None,
|
|
98
|
+
limit: LimitOpt = None,
|
|
99
|
+
everything: AllOpt = False,
|
|
100
|
+
json_out: JsonOpt = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""List the API keys of a Domain, including revoked and expired ones."""
|
|
103
|
+
app = app_of(ctx, json_out)
|
|
104
|
+
items, more = app.take(app.client().api_keys.iterate(app.domain(domain)), limit, everything)
|
|
105
|
+
app.out.emit(items, lambda: app.out.table([k.model_dump() for k in items], KEY_COLUMNS))
|
|
106
|
+
if more:
|
|
107
|
+
app.note_truncated(len(items))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@apikey_app.command("revoke")
|
|
111
|
+
def apikey_revoke(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
key_id: Annotated[
|
|
114
|
+
str, typer.Argument(help="Id of the key (see `apikey list`).", metavar="KEY_ID")
|
|
115
|
+
],
|
|
116
|
+
domain: DomainOpt = None,
|
|
117
|
+
json_out: JsonOpt = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Revoke an API key; it stops working at once."""
|
|
120
|
+
app = app_of(ctx, json_out)
|
|
121
|
+
app.client().api_keys.revoke(app.domain(domain), key_id)
|
|
122
|
+
app.out.emit({"revoked": key_id}, lambda: app.out.info(f"revoked key {key_id}"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@admin_app.command("erase")
|
|
126
|
+
def admin_erase(
|
|
127
|
+
ctx: typer.Context,
|
|
128
|
+
kind: Annotated[str, typer.Argument(help="Kind api name.", metavar="KIND")],
|
|
129
|
+
key: Annotated[
|
|
130
|
+
str | None, typer.Option("--key", help="Erase the values of the Record with this key.")
|
|
131
|
+
] = None,
|
|
132
|
+
attribute: Annotated[
|
|
133
|
+
list[str] | None,
|
|
134
|
+
typer.Option(
|
|
135
|
+
"--attribute",
|
|
136
|
+
help="Vault Attribute (api name). With --key: limit to it (repeatable); with "
|
|
137
|
+
"--value: the Attribute whose value is erased in every Record.",
|
|
138
|
+
),
|
|
139
|
+
] = None,
|
|
140
|
+
value: Annotated[
|
|
141
|
+
str | None,
|
|
142
|
+
typer.Option(
|
|
143
|
+
"--value",
|
|
144
|
+
metavar="- | @FILE",
|
|
145
|
+
help="Where the value to erase is read from (JSON, or plain text when it is not "
|
|
146
|
+
"valid JSON): - for standard input (asked without echo on a terminal), @FILE for a "
|
|
147
|
+
"file. A value given on the command line is refused: it would stay in the shell "
|
|
148
|
+
"history and the process list.",
|
|
149
|
+
),
|
|
150
|
+
] = None,
|
|
151
|
+
branch: Annotated[
|
|
152
|
+
str | None, typer.Option("--branch", help="Branch whose schema names the Kind.")
|
|
153
|
+
] = None,
|
|
154
|
+
domain: DomainOpt = None,
|
|
155
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
|
|
156
|
+
json_out: JsonOpt = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Erase personal data from the vault, on every branch and Revision. This cannot be undone.
|
|
159
|
+
|
|
160
|
+
`--key K` erases what the Record ever held in its vault Attributes; `--attribute A --value
|
|
161
|
+
V` erases V of A in every Record of the Kind. History keeps its shape: reads show the
|
|
162
|
+
values as erased.
|
|
163
|
+
"""
|
|
164
|
+
app = app_of(ctx, json_out)
|
|
165
|
+
if (key is None) == (value is None):
|
|
166
|
+
raise CliError("give --key, or --attribute and --value")
|
|
167
|
+
attributes = attribute or []
|
|
168
|
+
if value is not None and len(attributes) != 1:
|
|
169
|
+
raise CliError("erasure by --value takes exactly one --attribute")
|
|
170
|
+
text = None if value is None else _value_text(value)
|
|
171
|
+
target = f"{kind}/{key}" if key is not None else f"a value of {kind}.{attributes[0]}"
|
|
172
|
+
if not yes:
|
|
173
|
+
if not sys.stdin.isatty():
|
|
174
|
+
raise CliError("erasure is irreversible: pass --yes to confirm")
|
|
175
|
+
typer.confirm(f"Erase {target} from the vault for good?", abort=True)
|
|
176
|
+
vault = app.client().vault
|
|
177
|
+
if key is not None:
|
|
178
|
+
result = vault.erase_record(
|
|
179
|
+
app.domain(domain), kind, key, attributes=attributes or None, branch=branch
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
result = vault.erase_value(
|
|
183
|
+
app.domain(domain),
|
|
184
|
+
kind,
|
|
185
|
+
attributes[0],
|
|
186
|
+
_parsed(text or ""),
|
|
187
|
+
branch=branch,
|
|
188
|
+
)
|
|
189
|
+
app.out.emit(result, lambda: app.out.info(f"erased {result.erased} vault value(s)"))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _value_text(source: str) -> str:
|
|
193
|
+
"""The value read from standard input (`-`) or a file (`@FILE`)."""
|
|
194
|
+
if source == "-":
|
|
195
|
+
if sys.stdin.isatty():
|
|
196
|
+
return str(typer.prompt("Value to erase", hide_input=True))
|
|
197
|
+
return sys.stdin.read().rstrip("\n")
|
|
198
|
+
if source.startswith("@") and len(source) > 1:
|
|
199
|
+
try:
|
|
200
|
+
return Path(source[1:]).read_text(encoding="utf-8").rstrip("\n")
|
|
201
|
+
except OSError as error:
|
|
202
|
+
raise CliError(f"cannot read {source[1:]}: {error.strerror}") from None
|
|
203
|
+
raise CliError(
|
|
204
|
+
"give the value as --value - (standard input) or --value @FILE: a value on the "
|
|
205
|
+
"command line stays in the shell history and the process list"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _parsed(text: str) -> JsonValue:
|
|
210
|
+
try:
|
|
211
|
+
parsed: JsonValue = json.loads(text)
|
|
212
|
+
except ValueError:
|
|
213
|
+
return text
|
|
214
|
+
return parsed
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@vault_app.command("status")
|
|
218
|
+
def vault_status(ctx: typer.Context, json_out: JsonOpt = False) -> None:
|
|
219
|
+
"""Show which keys the vault rows use and which earlier keys can be removed."""
|
|
220
|
+
app = app_of(ctx, json_out)
|
|
221
|
+
keys = app.client().vault.keys()
|
|
222
|
+
|
|
223
|
+
def human() -> None:
|
|
224
|
+
app.out.pairs(
|
|
225
|
+
[
|
|
226
|
+
("encryption key", keys.key_id),
|
|
227
|
+
("token key", keys.token_key_id),
|
|
228
|
+
("rows by encryption key", _counts(keys.encryption_keys)),
|
|
229
|
+
("rows by fingerprint key", _counts(keys.fingerprint_keys)),
|
|
230
|
+
("tombstones by fingerprint key", _counts(keys.tombstone_keys)),
|
|
231
|
+
("rows to rotate", keys.outdated),
|
|
232
|
+
("removable previous_keys", ", ".join(keys.removable_keys) or "none"),
|
|
233
|
+
(
|
|
234
|
+
"removable previous_token_keys",
|
|
235
|
+
", ".join(keys.removable_token_keys) or "none",
|
|
236
|
+
),
|
|
237
|
+
("digest key", keys.digests),
|
|
238
|
+
]
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
app.out.emit(keys, human)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@vault_app.command("rotate")
|
|
245
|
+
def vault_rotate(ctx: typer.Context, json_out: JsonOpt = False) -> None:
|
|
246
|
+
"""Queue the job that seals and fingerprints the vault rows of every Domain again under the
|
|
247
|
+
current keys, in batches. Follow it with `admin vault status`."""
|
|
248
|
+
app = app_of(ctx, json_out)
|
|
249
|
+
queued = app.client().vault.rotate()
|
|
250
|
+
app.out.emit(queued, lambda: app.out.info(f"queued vault rotation job {queued.job_id}"))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _counts(counts: dict[str, int]) -> str:
|
|
254
|
+
return ", ".join(f"{key}: {count}" for key, count in counts.items()) or "none"
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`context`, `explain` and `health`: what an agent reads about Records, where their values came
|
|
2
|
+
from, and how sound the data is."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from lorebase_cli.commands.data import json_argument
|
|
11
|
+
from lorebase_cli.context import app_of
|
|
12
|
+
from lorebase_cli.failures import CliError
|
|
13
|
+
from lorebase_cli.options import AtOpt, BranchOpt, DomainOpt, JsonOpt
|
|
14
|
+
from lorebase_cli.output import Column, short_id
|
|
15
|
+
from lorebase_client import models as m
|
|
16
|
+
|
|
17
|
+
MarkdownOpt = Annotated[
|
|
18
|
+
bool, typer.Option("--markdown", help="Print the Markdown rendering instead of a summary.")
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
SEED_COLUMNS = [Column("record", "ref"), Column("title", "title"), Column("values", "values")]
|
|
22
|
+
NEIGHBOR_COLUMNS = [
|
|
23
|
+
Column("record", "ref"),
|
|
24
|
+
Column("title", "title"),
|
|
25
|
+
Column("depth", "depth"),
|
|
26
|
+
]
|
|
27
|
+
ATTRIBUTE_COLUMNS = [
|
|
28
|
+
Column("attribute", "name"),
|
|
29
|
+
Column("value", "value"),
|
|
30
|
+
Column("actor", "actor"),
|
|
31
|
+
Column("origin", "origin_type"),
|
|
32
|
+
Column("revision", "revision", short_id),
|
|
33
|
+
Column("at", "at"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def context_command(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
ref: Annotated[
|
|
40
|
+
list[str] | None,
|
|
41
|
+
typer.Option("--ref", help="Seed Record as KIND/KEY. Repeatable."),
|
|
42
|
+
] = None,
|
|
43
|
+
search: Annotated[
|
|
44
|
+
str | None, typer.Option("--search", help="Seed Records found by this text.")
|
|
45
|
+
] = None,
|
|
46
|
+
query: Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Option("--query", help="Seed Records of a DSL query: inline JSON, @file or -."),
|
|
49
|
+
] = None,
|
|
50
|
+
budget: Annotated[
|
|
51
|
+
int | None, typer.Option("--budget", min=1, help="Token budget of the pack.")
|
|
52
|
+
] = None,
|
|
53
|
+
depth: Annotated[
|
|
54
|
+
int | None, typer.Option("--depth", min=0, help="Hops to expand around the seeds.")
|
|
55
|
+
] = None,
|
|
56
|
+
relation: Annotated[
|
|
57
|
+
list[str] | None,
|
|
58
|
+
typer.Option("--relation", help="Follow only this Relation. Repeatable."),
|
|
59
|
+
] = None,
|
|
60
|
+
domain: DomainOpt = None,
|
|
61
|
+
branch: BranchOpt = None,
|
|
62
|
+
at: AtOpt = None,
|
|
63
|
+
markdown: MarkdownOpt = False,
|
|
64
|
+
json_out: JsonOpt = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Build a context pack: seed Records, their neighbors, schema and recent changes.
|
|
67
|
+
|
|
68
|
+
Name the seeds with exactly one of --ref, --search or --query.
|
|
69
|
+
"""
|
|
70
|
+
app = app_of(ctx, json_out)
|
|
71
|
+
parsed = None if query is None else json_argument(query)
|
|
72
|
+
if parsed is not None and not isinstance(parsed, dict):
|
|
73
|
+
raise CliError("a query is a JSON object")
|
|
74
|
+
request = m.ContextRequest(
|
|
75
|
+
branch=app.branch(branch),
|
|
76
|
+
at=at,
|
|
77
|
+
refs=ref or None,
|
|
78
|
+
query=parsed,
|
|
79
|
+
search=search,
|
|
80
|
+
budget_tokens=budget,
|
|
81
|
+
depth=depth,
|
|
82
|
+
relations=relation or None,
|
|
83
|
+
)
|
|
84
|
+
agents = app.client().agents
|
|
85
|
+
name = app.domain(domain)
|
|
86
|
+
if markdown:
|
|
87
|
+
app.out.text(agents.context_markdown(name, request))
|
|
88
|
+
return
|
|
89
|
+
pack = agents.context(name, request)
|
|
90
|
+
|
|
91
|
+
def human() -> None:
|
|
92
|
+
used = pack.budget.get("used_tokens")
|
|
93
|
+
limit = pack.budget.get("limit_tokens")
|
|
94
|
+
app.out.pairs(
|
|
95
|
+
[
|
|
96
|
+
("domain", pack.domain),
|
|
97
|
+
("branch", pack.branch),
|
|
98
|
+
("revision", short_id(pack.revision)),
|
|
99
|
+
("seeds", len(pack.seeds)),
|
|
100
|
+
("neighbors", len(pack.neighbors)),
|
|
101
|
+
("tokens", f"{used}/{limit}"),
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
app.out.table(pack.seeds, SEED_COLUMNS)
|
|
105
|
+
if pack.neighbors:
|
|
106
|
+
app.out.table(pack.neighbors, NEIGHBOR_COLUMNS)
|
|
107
|
+
|
|
108
|
+
app.out.emit(pack, human)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def explain_command(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
kind: Annotated[str, typer.Argument(help="Kind api name.", metavar="KIND")],
|
|
114
|
+
key: Annotated[str, typer.Argument(help="Record key.", metavar="KEY")],
|
|
115
|
+
domain: DomainOpt = None,
|
|
116
|
+
branch: BranchOpt = None,
|
|
117
|
+
at: AtOpt = None,
|
|
118
|
+
markdown: MarkdownOpt = False,
|
|
119
|
+
json_out: JsonOpt = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Explain a Record: where each value and Link came from, and the Proposals behind it."""
|
|
122
|
+
app = app_of(ctx, json_out)
|
|
123
|
+
agents = app.client().agents
|
|
124
|
+
name = app.domain(domain)
|
|
125
|
+
chosen = app.branch(branch)
|
|
126
|
+
if markdown:
|
|
127
|
+
app.out.text(agents.explain_markdown(name, kind, key, branch=chosen, at=at))
|
|
128
|
+
return
|
|
129
|
+
found = agents.explain(name, kind, key, branch=chosen, at=at)
|
|
130
|
+
|
|
131
|
+
def human() -> None:
|
|
132
|
+
record = found.record
|
|
133
|
+
app.out.pairs(
|
|
134
|
+
[
|
|
135
|
+
("record", record.get("ref")),
|
|
136
|
+
("title", record.get("title")),
|
|
137
|
+
("branch", found.branch),
|
|
138
|
+
("revision", short_id(found.revision)),
|
|
139
|
+
("created by", (found.created or {}).get("actor")),
|
|
140
|
+
("links", len(found.links)),
|
|
141
|
+
("changes", found.history.get("changes")),
|
|
142
|
+
("proposals", [f"{p.get('id')} ({p.get('status')})" for p in found.proposals]),
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
app.out.table(found.attributes, ATTRIBUTE_COLUMNS)
|
|
146
|
+
|
|
147
|
+
app.out.emit(found, human)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
KIND_HEALTH_COLUMNS = [
|
|
151
|
+
Column("kind", "kind"),
|
|
152
|
+
Column("records", "records"),
|
|
153
|
+
Column("invalid", "invalid"),
|
|
154
|
+
Column("duplicate groups", "duplicates"),
|
|
155
|
+
Column("derived", "derived"),
|
|
156
|
+
Column("last change", "last_change", short_id),
|
|
157
|
+
]
|
|
158
|
+
RELATION_HEALTH_COLUMNS = [
|
|
159
|
+
Column("relation", "relation"),
|
|
160
|
+
Column("from", "from_kind"),
|
|
161
|
+
Column("to", "to_kind"),
|
|
162
|
+
Column("links", "links"),
|
|
163
|
+
Column("orphans", "orphans"),
|
|
164
|
+
Column("gaps", "gaps"),
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _kind_rows(kinds: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
"kind": k["kind"],
|
|
172
|
+
"records": k["records"],
|
|
173
|
+
"invalid": k["violations"]["invalid_records"],
|
|
174
|
+
"duplicates": k["duplicates"]["total_groups"],
|
|
175
|
+
"derived": "stale" if (k.get("derived") or {}).get("stale") else "",
|
|
176
|
+
"last_change": (k.get("last_change") or {}).get("revision"),
|
|
177
|
+
}
|
|
178
|
+
for k in kinds
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _relation_rows(relations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
183
|
+
return [
|
|
184
|
+
{
|
|
185
|
+
**r,
|
|
186
|
+
"orphans": r["orphan_links"]["count"],
|
|
187
|
+
"gaps": None if r.get("required_gaps") is None else r["required_gaps"]["missing"],
|
|
188
|
+
}
|
|
189
|
+
for r in relations
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def health_command(
|
|
194
|
+
ctx: typer.Context,
|
|
195
|
+
kind: Annotated[
|
|
196
|
+
str | None,
|
|
197
|
+
typer.Argument(help="Report on this Kind only.", metavar="[KIND]"),
|
|
198
|
+
] = None,
|
|
199
|
+
domain: DomainOpt = None,
|
|
200
|
+
branch: BranchOpt = None,
|
|
201
|
+
at: AtOpt = None,
|
|
202
|
+
markdown: MarkdownOpt = False,
|
|
203
|
+
json_out: JsonOpt = False,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Check the data: constraint violations, orphan Links, required-Link gaps, duplicates."""
|
|
206
|
+
app = app_of(ctx, json_out)
|
|
207
|
+
agents = app.client().agents
|
|
208
|
+
name = app.domain(domain)
|
|
209
|
+
chosen = app.branch(branch)
|
|
210
|
+
if markdown:
|
|
211
|
+
app.out.text(
|
|
212
|
+
agents.kind_health_markdown(name, kind, branch=chosen, at=at)
|
|
213
|
+
if kind
|
|
214
|
+
else agents.health_markdown(name, branch=chosen, at=at)
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
found = (
|
|
218
|
+
agents.kind_health(name, kind, branch=chosen, at=at)
|
|
219
|
+
if kind
|
|
220
|
+
else agents.health(name, branch=chosen, at=at)
|
|
221
|
+
)
|
|
222
|
+
kinds = [found.health] if isinstance(found, m.KindHealthReport) else found.kinds
|
|
223
|
+
|
|
224
|
+
def human() -> None:
|
|
225
|
+
summary = found.summary
|
|
226
|
+
app.out.pairs(
|
|
227
|
+
[
|
|
228
|
+
("domain", found.domain),
|
|
229
|
+
("branch", found.branch),
|
|
230
|
+
("revision", short_id(found.revision)),
|
|
231
|
+
("status", "ok" if summary.ok else "needs attention"),
|
|
232
|
+
("records", summary.records),
|
|
233
|
+
("invalid records", summary.invalid_records),
|
|
234
|
+
("orphan links", summary.orphan_links),
|
|
235
|
+
("required-link gaps", summary.required_relation_gaps),
|
|
236
|
+
("duplicate groups", summary.duplicate_groups),
|
|
237
|
+
("kinds with stale derived values", summary.stale_kinds),
|
|
238
|
+
]
|
|
239
|
+
)
|
|
240
|
+
app.out.table(_kind_rows(kinds), KIND_HEALTH_COLUMNS)
|
|
241
|
+
if found.relations:
|
|
242
|
+
app.out.table(_relation_rows(found.relations), RELATION_HEALTH_COLUMNS)
|
|
243
|
+
|
|
244
|
+
app.out.emit(found, human)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""`audit verify`: check the hash chain of a Domain's audit log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from lorebase_cli.context import app_of
|
|
10
|
+
from lorebase_cli.failures import EXIT_INVALID
|
|
11
|
+
from lorebase_cli.options import DomainOpt, JsonOpt
|
|
12
|
+
|
|
13
|
+
audit_app = typer.Typer(help="Check the audit log of a Domain.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@audit_app.command("verify")
|
|
17
|
+
def audit_verify(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
domain: DomainOpt = None,
|
|
20
|
+
after_seq: Annotated[
|
|
21
|
+
int | None,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--after-seq",
|
|
24
|
+
min=0,
|
|
25
|
+
help="Start after this entry, trusting it (the last seq of an earlier run).",
|
|
26
|
+
),
|
|
27
|
+
] = None,
|
|
28
|
+
json_out: JsonOpt = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Recompute the audit hash chain of a Domain and report the first broken entry.
|
|
31
|
+
|
|
32
|
+
Needs the audit.read permission (the admin role). Exits with 2 when an entry does not
|
|
33
|
+
verify.
|
|
34
|
+
"""
|
|
35
|
+
app = app_of(ctx, json_out)
|
|
36
|
+
result = app.client().audit.verify(app.domain(domain), after_seq=after_seq)
|
|
37
|
+
|
|
38
|
+
def human() -> None:
|
|
39
|
+
if result.first_broken is None:
|
|
40
|
+
app.out.text(f"intact: {result.checked} entries verified")
|
|
41
|
+
else:
|
|
42
|
+
broken = result.first_broken
|
|
43
|
+
app.out.text(
|
|
44
|
+
f"broken at seq {broken.seq}: {broken.reason}; "
|
|
45
|
+
f"{result.checked} entries verified before it"
|
|
46
|
+
)
|
|
47
|
+
app.out.pairs([("domain", result.domain_id), ("last seq", result.last_seq)])
|
|
48
|
+
|
|
49
|
+
app.out.emit(result, human)
|
|
50
|
+
if not result.intact:
|
|
51
|
+
raise typer.Exit(EXIT_INVALID)
|