duckhaven-cli 0.1.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.
dh/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """`dh` — the DuckHaven command-line interface.
2
+
3
+ A pure HTTP client of DuckHaven's public REST API, authenticating with a Personal
4
+ Access Token. The interactive REPL holds a session through
5
+ ``duckhaven-sql-connector``; everything else, one-shot ``dh sql`` included, goes
6
+ over the REST API directly -- the one-shot query route is the one that works when
7
+ SQL sessions are disabled, and the connector does not expose it.
8
+ """
9
+
10
+ try: # populated by hatch-vcs at build time
11
+ from ._version import __version__
12
+ except ImportError: # pragma: no cover - source checkout without a build
13
+ try:
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ __version__ = version("duckhaven-cli")
17
+ except PackageNotFoundError: # pragma: no cover
18
+ __version__ = "0.0.0+unknown"
19
+
20
+ __all__ = ["__version__"]
dh/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
File without changes
dh/commands/admin.py ADDED
@@ -0,0 +1,353 @@
1
+ """`dh admin` — the operator surface, hand-written but thin.
2
+
3
+ Deliberately not the whole `admin` tag. These are the operations operators
4
+ actually script: issuing credentials, managing who has them, and agent
5
+ lifecycle. Storage-backend creation and per-agent grants are install-time,
6
+ once-off and high blast radius, so they stay behind `dh api` until someone
7
+ asks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from dh import context
15
+
16
+ app = typer.Typer(name="admin", help="Operator tasks: accounts, users, agents.")
17
+
18
+ sa_app = typer.Typer(name="service-account", help="Machine identities for unattended callers.")
19
+ pat_app = typer.Typer(name="pat", help="Tokens issued to a service account.")
20
+ user_app = typer.Typer(name="user", help="People and their workspace roles.")
21
+ agent_app = typer.Typer(name="agent", help="Compute agents.")
22
+ storage_app = typer.Typer(name="storage", help="Storage backends.")
23
+ maintenance_app = typer.Typer(name="maintenance", help="Deployment maintenance policy.")
24
+
25
+ for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app):
26
+ app.add_typer(sub)
27
+
28
+
29
+ # --- Service accounts ------------------------------------------------------
30
+
31
+
32
+ @sa_app.command("list")
33
+ def list_service_accounts(ctx: typer.Context, fetch_all: bool = typer.Option(False, "--all")):
34
+ """Service accounts and how many live tokens each holds."""
35
+ cli = context.of(ctx)
36
+ with cli.client() as client:
37
+ cli.page(client, "admin/service-accounts", fetch_all=fetch_all)
38
+
39
+
40
+ @sa_app.command("create")
41
+ def create_service_account(
42
+ ctx: typer.Context,
43
+ name: str,
44
+ role: str = typer.Option("user", "--role", help="Global role. Defaults to no permissions."),
45
+ ):
46
+ """Create a service account.
47
+
48
+ The role defaults to `user`, which carries no global permissions, so a new
49
+ account is never accidentally an admin.
50
+ """
51
+ cli = context.of(ctx)
52
+ with cli.client() as client:
53
+ cli.emit(client.post("admin/service-accounts", json={"name": name, "role": role}))
54
+
55
+
56
+ @sa_app.command("update")
57
+ def update_service_account(
58
+ ctx: typer.Context,
59
+ service_account_id: str,
60
+ role: str = typer.Option(None, "--role"),
61
+ active: bool = typer.Option(None, "--active/--inactive"),
62
+ ):
63
+ """Change a service account's role, or disable it.
64
+
65
+ Disabling blocks every one of its tokens immediately, which is the fast way
66
+ to cut off a compromised credential without hunting down each PAT.
67
+ """
68
+ cli = context.of(ctx)
69
+ body = {k: v for k, v in {"role": role, "is_active": active}.items() if v is not None}
70
+ with cli.client() as client:
71
+ cli.emit(client.patch(f"admin/service-accounts/{service_account_id}", json=body))
72
+
73
+
74
+ @sa_app.command("delete")
75
+ def delete_service_account(ctx: typer.Context, service_account_id: str):
76
+ """Delete a service account.
77
+
78
+ Refused with a 409 once the account has run queries, because deleting it
79
+ would orphan that audit trail; disable it instead.
80
+ """
81
+ cli = context.of(ctx)
82
+ with cli.client() as client:
83
+ client.delete(f"admin/service-accounts/{service_account_id}")
84
+ cli.note(f"Deleted service account {service_account_id}.")
85
+
86
+
87
+ # --- Tokens ----------------------------------------------------------------
88
+
89
+
90
+ @pat_app.command("issue")
91
+ def issue_pat(
92
+ ctx: typer.Context,
93
+ service_account_id: str,
94
+ expires_in_days: int = typer.Option(
95
+ 90, "--expires-in-days", help="Pass 0 for a token that never expires."
96
+ ),
97
+ ):
98
+ """Issue a token for a service account. The secret is shown once.
99
+
100
+ This is the credential CI should use. A person signing in for themselves
101
+ uses `dh auth login` instead, which mints a bounded token for their own
102
+ identity.
103
+ """
104
+ cli = context.of(ctx)
105
+ body = {"expires_in_days": None if expires_in_days == 0 else expires_in_days}
106
+ with cli.client() as client:
107
+ issued = client.post(f"admin/service-accounts/{service_account_id}/pats", json=body)
108
+ cli.note("Copy the token now; it cannot be shown again.")
109
+ cli.emit(issued)
110
+
111
+
112
+ @pat_app.command("list")
113
+ def list_pats(ctx: typer.Context, service_account_id: str):
114
+ """Tokens issued to a service account: creation and expiry, never the secret."""
115
+ cli = context.of(ctx)
116
+ with cli.client() as client:
117
+ cli.emit(client.get(f"admin/service-accounts/{service_account_id}/pats"))
118
+
119
+
120
+ @pat_app.command("revoke")
121
+ def revoke_pat(ctx: typer.Context, service_account_id: str, pat_id: str):
122
+ """Revoke one token. It stops authenticating immediately."""
123
+ cli = context.of(ctx)
124
+ with cli.client() as client:
125
+ client.delete(f"admin/service-accounts/{service_account_id}/pats/{pat_id}")
126
+ cli.note(f"Revoked token {pat_id}.")
127
+
128
+
129
+ # --- Users -----------------------------------------------------------------
130
+
131
+
132
+ @user_app.command("list")
133
+ def list_users(ctx: typer.Context, fetch_all: bool = typer.Option(False, "--all")):
134
+ """People known to the deployment."""
135
+ cli = context.of(ctx)
136
+ with cli.client() as client:
137
+ cli.page(client, "admin/users", fetch_all=fetch_all)
138
+
139
+
140
+ @user_app.command("create")
141
+ def create_user(
142
+ ctx: typer.Context,
143
+ email: str,
144
+ name: str = typer.Option(..., "--name"),
145
+ password: str = typer.Option(..., "--password", prompt=True, hide_input=True),
146
+ role: str = typer.Option("user", "--role"),
147
+ ):
148
+ """Create a local user. The password is prompted for rather than passed as a flag."""
149
+ cli = context.of(ctx)
150
+ body = {"email": email, "name": name, "password": password, "role": role}
151
+ with cli.client() as client:
152
+ cli.emit(client.post("admin/users", json=body))
153
+
154
+
155
+ @user_app.command("update")
156
+ def update_user(
157
+ ctx: typer.Context,
158
+ user_id: str,
159
+ role: str = typer.Option(None, "--role"),
160
+ active: bool = typer.Option(None, "--active/--inactive"),
161
+ ):
162
+ """Change a user's global role, or deactivate them."""
163
+ cli = context.of(ctx)
164
+ body = {k: v for k, v in {"role": role, "is_active": active}.items() if v is not None}
165
+ with cli.client() as client:
166
+ cli.emit(client.patch(f"admin/users/{user_id}", json=body))
167
+
168
+
169
+ @user_app.command("revoke-sessions")
170
+ def revoke_sessions(ctx: typer.Context, user_id: str):
171
+ """Sign a user out everywhere. Their tokens are unaffected."""
172
+ cli = context.of(ctx)
173
+ with cli.client() as client:
174
+ client.post(f"admin/users/{user_id}/revoke-sessions")
175
+ cli.note(f"Revoked every session for {user_id}.")
176
+
177
+
178
+ @user_app.command("workspaces")
179
+ def user_workspaces(ctx: typer.Context, user_id: str):
180
+ """Which workspaces a user belongs to, and in what role."""
181
+ cli = context.of(ctx)
182
+ with cli.client() as client:
183
+ cli.emit(client.get(f"admin/users/{user_id}/workspaces"))
184
+
185
+
186
+ @user_app.command("set-workspace-role")
187
+ def set_workspace_role(ctx: typer.Context, user_id: str, workspace: str, role: str):
188
+ """Add a user to a workspace, or change the role they hold there."""
189
+ cli = context.of(ctx)
190
+ with cli.client() as client:
191
+ cli.emit(client.put(f"admin/users/{user_id}/workspaces/{workspace}", json={"role": role}))
192
+
193
+
194
+ @user_app.command("remove-from-workspace")
195
+ def remove_from_workspace(ctx: typer.Context, user_id: str, workspace: str):
196
+ """Remove a user from a workspace."""
197
+ cli = context.of(ctx)
198
+ with cli.client() as client:
199
+ client.delete(f"admin/users/{user_id}/workspaces/{workspace}")
200
+ cli.note(f"Removed {user_id} from {workspace}.")
201
+
202
+
203
+ # --- Agents ----------------------------------------------------------------
204
+
205
+
206
+ @agent_app.command("list")
207
+ def list_agents(ctx: typer.Context):
208
+ """Every agent, including ones this caller could not run queries on."""
209
+ cli = context.of(ctx)
210
+ with cli.client() as client:
211
+ cli.emit(client.get("admin/agents"))
212
+
213
+
214
+ @agent_app.command("get")
215
+ def get_agent(ctx: typer.Context, agent_id: str):
216
+ """One agent's registration, capabilities and lifecycle state."""
217
+ cli = context.of(ctx)
218
+ with cli.client() as client:
219
+ cli.emit(client.get(f"admin/agents/{agent_id}"))
220
+
221
+
222
+ @agent_app.command("bootstrap")
223
+ def bootstrap_agent(ctx: typer.Context):
224
+ """Mint a single-use token for an agent to register itself.
225
+
226
+ The response carries the WebSocket URL and image as well, so it is
227
+ everything needed to start an agent. Shown once and not listable.
228
+ """
229
+ cli = context.of(ctx)
230
+ with cli.client() as client:
231
+ token = client.post("admin/agents/bootstrap")
232
+ cli.note("Copy this now; a bootstrap token is single-use and cannot be listed again.")
233
+ cli.emit(token)
234
+
235
+
236
+ @agent_app.command("elastic-create")
237
+ def create_elastic_agent(
238
+ ctx: typer.Context,
239
+ cpu: float = typer.Option(..., "--cpu"),
240
+ memory_gb: float = typer.Option(..., "--memory-gb"),
241
+ idle_timeout_minutes: int = typer.Option(None, "--idle-timeout-minutes"),
242
+ ):
243
+ """Provision an elastic agent. Accepted asynchronously; poll `admin agent get`."""
244
+ cli = context.of(ctx)
245
+ body: dict[str, object] = {"cpu": cpu, "memory_gb": memory_gb}
246
+ if idle_timeout_minutes is not None:
247
+ body["idle_timeout_minutes"] = idle_timeout_minutes
248
+ with cli.client() as client:
249
+ cli.emit(client.post("admin/agents/elastic", json=body))
250
+
251
+
252
+ @agent_app.command("compute-options")
253
+ def compute_options(ctx: typer.Context):
254
+ """The CPU and memory shapes this deployment will provision."""
255
+ cli = context.of(ctx)
256
+ with cli.client() as client:
257
+ cli.emit(client.get("admin/agents/compute-options"))
258
+
259
+
260
+ @agent_app.command("metrics")
261
+ def agent_metrics(ctx: typer.Context):
262
+ """Current load and capacity for every agent."""
263
+ cli = context.of(ctx)
264
+ with cli.client() as client:
265
+ cli.emit(client.get("admin/agents/metrics"))
266
+
267
+
268
+ @agent_app.command("monitoring")
269
+ def agent_monitoring(ctx: typer.Context, agent_id: str):
270
+ """One agent's monitoring detail."""
271
+ cli = context.of(ctx)
272
+ with cli.client() as client:
273
+ cli.emit(client.get(f"admin/agents/{agent_id}/monitoring"))
274
+
275
+
276
+ @agent_app.command("access")
277
+ def agent_access(ctx: typer.Context, agent_id: str):
278
+ """Who may use this agent, and how that was decided."""
279
+ cli = context.of(ctx)
280
+ with cli.client() as client:
281
+ cli.emit(client.get(f"admin/agents/{agent_id}/access"))
282
+
283
+
284
+ def _agent_action(action: str, doc: str):
285
+ """The lifecycle transitions, which differ only in their path segment."""
286
+
287
+ def command(ctx: typer.Context, agent_id: str) -> None:
288
+ cli = context.of(ctx)
289
+ with cli.client() as client:
290
+ cli.emit(client.post(f"admin/agents/{agent_id}/{action}"))
291
+
292
+ command.__doc__ = doc
293
+ return command
294
+
295
+
296
+ agent_app.command("restart")(_agent_action("restart", "Restart an elastic agent."))
297
+ agent_app.command("terminate")(_agent_action("terminate", "Terminate an elastic agent."))
298
+ agent_app.command("disconnect")(
299
+ _agent_action("disconnect", "Force an agent's control channel closed. It may reconnect.")
300
+ )
301
+
302
+
303
+ @agent_app.command("delete")
304
+ def delete_agent(ctx: typer.Context, agent_id: str):
305
+ """Delete an agent registration."""
306
+ cli = context.of(ctx)
307
+ with cli.client() as client:
308
+ client.delete(f"admin/agents/{agent_id}")
309
+ cli.note(f"Deleted agent {agent_id}.")
310
+
311
+
312
+ @agent_app.command("revoke-credential")
313
+ def revoke_agent_credential(ctx: typer.Context, agent_id: str):
314
+ """Revoke an agent's credential, so it cannot reconnect."""
315
+ cli = context.of(ctx)
316
+ with cli.client() as client:
317
+ client.delete(f"admin/agents/{agent_id}/credential")
318
+ cli.note(f"Revoked the credential for agent {agent_id}.")
319
+
320
+
321
+ # --- Storage and maintenance ----------------------------------------------
322
+
323
+
324
+ @storage_app.command("list")
325
+ def list_backends(ctx: typer.Context):
326
+ """Configured storage backends."""
327
+ cli = context.of(ctx)
328
+ with cli.client() as client:
329
+ cli.emit(client.get("admin/storage-backends"))
330
+
331
+
332
+ @storage_app.command("health")
333
+ def backend_health(ctx: typer.Context, storage_backend_id: str):
334
+ """Check a backend end to end, by vending credentials against a probe table."""
335
+ cli = context.of(ctx)
336
+ with cli.client() as client:
337
+ cli.emit(client.post(f"admin/storage-backends/{storage_backend_id}/health"))
338
+
339
+
340
+ @maintenance_app.command("policy")
341
+ def get_policy(ctx: typer.Context):
342
+ """The deployment's maintenance policy."""
343
+ cli = context.of(ctx)
344
+ with cli.client() as client:
345
+ cli.emit(client.get("admin/maintenance/policy"))
346
+
347
+
348
+ @maintenance_app.command("scan")
349
+ def trigger_scan(ctx: typer.Context):
350
+ """Run a maintenance scan now rather than waiting for the schedule."""
351
+ cli = context.of(ctx)
352
+ with cli.client() as client:
353
+ cli.emit(client.post("admin/maintenance/scan"))
dh/commands/api.py ADDED
@@ -0,0 +1,79 @@
1
+ """`dh api` — a raw request against any endpoint.
2
+
3
+ The escape hatch that makes the coverage cut honest. Roughly forty operations get
4
+ no hand-written command, and without this that would mean "you cannot do it from
5
+ the CLI" rather than "you type the path yourself".
6
+
7
+ It still carries the parts worth having: the `/api` mount, the bearer token, the
8
+ error envelope, and the exit-code contract. Only the path and body are yours.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import typer
19
+
20
+ from dh import context
21
+ from dh.errors import ConflictError
22
+
23
+ app = typer.Typer(name="api", help="Call any endpoint directly.")
24
+
25
+ _DATA = typer.Option(
26
+ None, "--data", "-d", help="JSON body, or @file to read one. Use @- for standard input."
27
+ )
28
+ _PARAM = typer.Option(None, "--param", "-p", help="Repeatable query parameter: name=value.")
29
+
30
+
31
+ def _body(data: str | None) -> Any:
32
+ if data is None:
33
+ return None
34
+ if data == "@-":
35
+ raw = sys.stdin.read()
36
+ elif data.startswith("@"):
37
+ path = Path(data[1:])
38
+ if not path.exists():
39
+ raise ConflictError("no_such_file", f"No such file: {path}")
40
+ raw = path.read_text(encoding="utf-8")
41
+ else:
42
+ raw = data
43
+ try:
44
+ return json.loads(raw)
45
+ except json.JSONDecodeError as exc:
46
+ raise ConflictError("bad_json", f"--data is not valid JSON: {exc}") from exc
47
+
48
+
49
+ def _params(pairs: list[str]) -> dict[str, list[str]]:
50
+ """Query parameters, keeping repeats so `status` can be given twice."""
51
+ out: dict[str, list[str]] = {}
52
+ for pair in pairs or []:
53
+ name, sep, value = pair.partition("=")
54
+ if not sep:
55
+ raise ConflictError("bad_param", f"Could not read {pair!r}. Use name=value.")
56
+ out.setdefault(name, []).append(value)
57
+ return out
58
+
59
+
60
+ def _verb(method: str, doc: str):
61
+ def command(
62
+ ctx: typer.Context,
63
+ path: str = typer.Argument(..., help="Path below /api, e.g. workspaces/analytics/queries."),
64
+ data: str = _DATA,
65
+ param: list[str] = _PARAM,
66
+ ) -> None:
67
+ cli = context.of(ctx)
68
+ with cli.client() as client:
69
+ cli.emit(client.request(method, path, json=_body(data), params=_params(param) or None))
70
+
71
+ command.__doc__ = doc
72
+ return command
73
+
74
+
75
+ app.command("get")(_verb("GET", "Send a GET request."))
76
+ app.command("post")(_verb("POST", "Send a POST request."))
77
+ app.command("put")(_verb("PUT", "Send a PUT request."))
78
+ app.command("patch")(_verb("PATCH", "Send a PATCH request."))
79
+ app.command("delete")(_verb("DELETE", "Send a DELETE request."))