mkctl 0.4.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.
mkctl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """mkctl — the command line for the control plane.
2
+
3
+ A human's terminal, which must not hold a password and must be revocable
4
+ independently of the account (ADR 0004). It holds tokens the identity provider
5
+ issued, in the OS keyring, and never a credential this platform minted.
6
+ """
7
+
8
+ __version__ = "0.1.0"
mkctl/cli.py ADDED
@@ -0,0 +1,478 @@
1
+ """`mkctl` — log in, and then drive the control plane you logged into.
2
+
3
+ `C14`. Until this, the CLI authenticated against an API it could not use: `login`,
4
+ `logout` and `whoami` were the whole of it, which made the first thing a person does with
5
+ this platform also the last. Every command below is a call to an endpoint that already
6
+ existed — what was missing was the terminal, not the platform.
7
+
8
+ Where the options come from is deliberate. A control plane, an issuer and a client id are
9
+ per-installation and live in the environment; a project and an environment are per-command
10
+ but almost always the same all day, so they take `MKCTL_PROJECT` and `MKCTL_ENVIRONMENT`
11
+ as defaults. A role's scope does **not**: `mkctl role grant … ` with no scope means the
12
+ tenant, and letting an environment variable widen or narrow a grant would make the same
13
+ command give different power on two machines.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import datetime as dt
20
+ import importlib.metadata
21
+ import os
22
+ import sys
23
+ from collections.abc import Callable
24
+ from contextlib import suppress
25
+
26
+ import httpx
27
+ from mkp_contracts.roles import ROLES
28
+
29
+ from . import client, commands, config
30
+ from .client import ControlPlane, ControlPlaneError
31
+ from .oauth import AuthorizationError
32
+ from .session import Session
33
+ from .store import StoreUnavailable, TokenStore
34
+
35
+ CONTROL_PLANE_ENV = "MKCTL_CONTROL_PLANE"
36
+ ISSUER_ENV = "MKCTL_OIDC_ISSUER"
37
+ CLIENT_ID_ENV = "MKCTL_CLIENT_ID"
38
+ PROJECT_ENV = "MKCTL_PROJECT"
39
+ ENVIRONMENT_ENV = "MKCTL_ENVIRONMENT"
40
+
41
+ Command = Callable[[ControlPlane, argparse.Namespace], int]
42
+
43
+
44
+ def build_session(args: argparse.Namespace, *, allow_discovery: bool = False) -> Session:
45
+ """Resolve where and how to authenticate: flag > env > config file > discovery.
46
+
47
+ Discovery (`C32`) runs only for `login`, and only for what is still missing: the
48
+ control plane serves its own issuer and CLI client id at `GET /v1/cli-config`, so
49
+ the one value an operator needs is the URL. What discovery learns is cached in
50
+ `config.py`'s file; what a person set themselves never is — a copy of their
51
+ environment in a second location would just be drift waiting to disagree.
52
+ """
53
+ saved = config.load()
54
+ control_plane = (
55
+ args.control_plane or os.environ.get(CONTROL_PLANE_ENV) or saved.get("control_plane")
56
+ )
57
+ issuer = args.issuer or os.environ.get(ISSUER_ENV) or saved.get("issuer")
58
+ client_id = args.client_id or os.environ.get(CLIENT_ID_ENV) or saved.get("client_id")
59
+
60
+ if allow_discovery and control_plane and (not issuer or not client_id):
61
+ response = httpx.get(
62
+ f"{control_plane.rstrip('/')}/v1/cli-config",
63
+ timeout=10,
64
+ headers={"User-Agent": client.USER_AGENT},
65
+ )
66
+ if response.status_code == 404:
67
+ raise SystemExit(
68
+ "mkctl: this control plane declares no CLI configuration — "
69
+ f"pass --issuer and --client-id (or set ${ISSUER_ENV}, ${CLIENT_ID_ENV})"
70
+ )
71
+ response.raise_for_status()
72
+ declared = response.json()
73
+ issuer = issuer or declared["issuer"]
74
+ client_id = client_id or declared["client_id"]
75
+ config.save(control_plane, issuer, client_id)
76
+ print(f"Discovered issuer and client id from {control_plane} (cached)")
77
+
78
+ if not (control_plane and issuer and client_id):
79
+ missing = [
80
+ name
81
+ for name, value in (
82
+ (CONTROL_PLANE_ENV, control_plane),
83
+ (ISSUER_ENV, issuer),
84
+ (CLIENT_ID_ENV, client_id),
85
+ )
86
+ if not value
87
+ ]
88
+ raise SystemExit(
89
+ f"mkctl: set {', '.join(missing)} (or pass the matching option) — "
90
+ "or run `mkctl login --control-plane <url>` and let the platform say"
91
+ )
92
+ return Session(
93
+ control_plane=control_plane,
94
+ issuer=issuer,
95
+ client_id=client_id,
96
+ store=TokenStore(control_plane),
97
+ )
98
+
99
+
100
+ def build_parser() -> argparse.ArgumentParser:
101
+ parser = argparse.ArgumentParser(prog="mkctl", description=(__doc__ or "").splitlines()[0])
102
+ parser.add_argument(
103
+ "--version",
104
+ action="version",
105
+ version=f"%(prog)s {importlib.metadata.version('mkctl')}",
106
+ )
107
+ parser.add_argument("--control-plane", help=f"or ${CONTROL_PLANE_ENV}")
108
+ parser.add_argument("--issuer", help=f"or ${ISSUER_ENV}")
109
+ parser.add_argument("--client-id", help=f"or ${CLIENT_ID_ENV}")
110
+ subparsers = parser.add_subparsers(dest="command", required=True)
111
+
112
+ output = argparse.ArgumentParser(add_help=False)
113
+ output.add_argument(
114
+ "--json",
115
+ action="store_true",
116
+ help="the control plane's answer, unformatted — for scripts",
117
+ )
118
+
119
+ # -- the login itself ---------------------------------------------------
120
+ login = subparsers.add_parser("login", help="sign in through the identity provider")
121
+ login.add_argument(
122
+ "--device",
123
+ action="store_true",
124
+ help="use the device grant instead of a browser — for SSH and headless machines",
125
+ )
126
+ login.add_argument("--no-browser", action="store_true", help="print the URL, do not open it")
127
+
128
+ subparsers.add_parser("logout", help="revoke this device's token and forget it")
129
+ subparsers.add_parser(
130
+ "upgrade",
131
+ help="print the upgrade procedure for the latest release (ADR 0014)",
132
+ )
133
+ subparsers.add_parser("whoami", parents=[output], help="who the control plane thinks you are")
134
+
135
+ # -- bundles and the pointer -------------------------------------------
136
+ bundle = _group(subparsers, "bundle", "what `mkbundle build` produced")
137
+ push = bundle.add_parser("push", parents=[output], help="register a bundle and upload it")
138
+ push.add_argument("archive", help="the .tar.gz `mkbundle build` wrote")
139
+ _target(push, environment=False)
140
+
141
+ deploy = subparsers.add_parser(
142
+ "deploy", parents=[output], help="point an environment at a bundle"
143
+ )
144
+ deploy.add_argument("digest", help="the bundle's digest, which is its name")
145
+ _target(deploy)
146
+
147
+ # -- projects and environments -----------------------------------------
148
+ project = _group(subparsers, "project", "projects in this tenant")
149
+ project.add_parser("list", parents=[output], help="every project")
150
+ project_create = project.add_parser("create", parents=[output], help="a new project")
151
+ project_create.add_argument("slug")
152
+
153
+ environment = _group(subparsers, "env", "environments in a project")
154
+ _target(environment.add_parser("list", parents=[output], help="every environment"), False)
155
+ env_create = environment.add_parser("create", parents=[output], help="a new environment")
156
+ env_create.add_argument("name")
157
+ _target(env_create, environment=False)
158
+ env_get = environment.add_parser("get", parents=[output], help="what is deployed there")
159
+ env_get.add_argument("name")
160
+ _target(env_get, environment=False)
161
+
162
+ # -- runs ---------------------------------------------------------------
163
+ run = _group(subparsers, "run", "start, list, read, answer, or abandon")
164
+ start = run.add_parser("start", parents=[output], help="start a run of a deployed workflow")
165
+ start.add_argument("workflow")
166
+ start.add_argument(
167
+ "--input",
168
+ action="append",
169
+ metavar="NAME=VALUE",
170
+ help="a string input; repeatable",
171
+ )
172
+ start.add_argument(
173
+ "--input-json",
174
+ metavar="JSON",
175
+ help="the inputs as a JSON object, for anything that is not a string",
176
+ )
177
+ _target(start)
178
+
179
+ list_runs = run.add_parser("list", parents=[output], help="runs, newest first")
180
+ list_runs.add_argument("--status", help="filter by status (running, waiting, …)")
181
+ list_runs.add_argument("--project", help="filter by project name")
182
+ list_runs.add_argument("--environment", help="filter by environment name")
183
+ list_runs.add_argument("--workflow", help="filter by workflow name")
184
+ list_runs.add_argument("--limit", type=int, help="page size (API default 50, max 200)")
185
+ list_runs.add_argument("--cursor", help="continue a previous page")
186
+
187
+ get = run.add_parser("get", parents=[output], help="a run, its attempts, and what it waits on")
188
+ get.add_argument("run_id")
189
+
190
+ reply = run.add_parser("reply", parents=[output], help="reply to a waiting run")
191
+ reply.add_argument("run_id")
192
+ answer = reply.add_mutually_exclusive_group(required=True)
193
+ answer.add_argument("--text", help="what to tell the gate")
194
+ answer.add_argument("--json-payload", metavar="JSON", help="an answer that is not prose")
195
+ reply.add_argument(
196
+ "--step",
197
+ help="which gate, when a run waits on more than one",
198
+ )
199
+
200
+ abandon = run.add_parser("abandon", parents=[output], help="cancel a waiting run")
201
+ abandon.add_argument("run_id")
202
+ abandon.add_argument("--reason", help="why, recorded on the outbound row")
203
+
204
+ # -- people -------------------------------------------------------------
205
+ user = _group(subparsers, "user", "who is in this tenant")
206
+ user.add_parser("list", parents=[output], help="every user, and what each may do")
207
+ user_create = user.add_parser("create", parents=[output], help="mirror an IdP subject in")
208
+ user_create.add_argument("--subject", required=True, help="the IdP's subject claim")
209
+ user_create.add_argument("--email", required=True)
210
+
211
+ role = _group(subparsers, "role", "what a user may do, and where")
212
+ grant = role.add_parser("grant", parents=[output], help="give a user a role")
213
+ _role_arguments(grant)
214
+ revoke = role.add_parser("revoke", parents=[output], help="take a role away")
215
+ _role_arguments(revoke)
216
+
217
+ # -- above a tenant, and beside it --------------------------------------
218
+ tenant = _group(subparsers, "tenant", "needs the platform grant, not a role")
219
+ tenant.add_parser("list", parents=[output], help="every tenant")
220
+ tenant_create = tenant.add_parser("create", parents=[output], help="a tenant and its owner")
221
+ tenant_create.add_argument("slug")
222
+ tenant_create.add_argument("--owner-subject", required=True)
223
+ tenant_create.add_argument("--owner-email", required=True)
224
+ # `C15`. Omitted means unlimited — the honest default while every tenant is ours.
225
+ tenant_create.add_argument("--monthly-run-limit", type=int)
226
+ tenant_create.add_argument("--monthly-token-limit", type=int)
227
+
228
+ connection = _group(subparsers, "connection", "credentials, by label")
229
+ connection.add_parser("list", parents=[output], help="every connection in this tenant")
230
+ conn_create = connection.add_parser(
231
+ "create",
232
+ parents=[output],
233
+ help="store a credential (omit --material for auth: none)",
234
+ )
235
+ conn_create.add_argument(
236
+ "--connector", required=True, help="connector name (e.g. vendor, http)"
237
+ )
238
+ conn_create.add_argument("--label", required=True, help="human label in this tenant")
239
+ conn_create.add_argument(
240
+ "--material",
241
+ action="append",
242
+ metavar="NAME=VALUE",
243
+ help="credential field; repeatable. omit all for an authless connection (D10)",
244
+ )
245
+ conn_bind = connection.add_parser(
246
+ "bind",
247
+ parents=[output],
248
+ help="bind a connection name in an environment",
249
+ )
250
+ conn_bind.add_argument("name", help="the name workflows use (e.g. mailbox)")
251
+ conn_bind.add_argument(
252
+ "--connection-id",
253
+ required=True,
254
+ help="id from connection create/list",
255
+ )
256
+ _target(conn_bind)
257
+
258
+ return parser
259
+
260
+
261
+ COMMANDS: dict[tuple[str, str | None], Command] = {
262
+ ("whoami", None): commands.whoami,
263
+ ("bundle", "push"): commands.bundle_push,
264
+ ("deploy", None): commands.deploy,
265
+ ("project", "list"): commands.project_list,
266
+ ("project", "create"): commands.project_create,
267
+ ("env", "list"): commands.environment_list,
268
+ ("env", "create"): commands.environment_create,
269
+ ("env", "get"): commands.environment_get,
270
+ ("run", "start"): commands.run_start,
271
+ ("run", "list"): commands.run_list,
272
+ ("run", "get"): commands.run_get,
273
+ ("run", "reply"): commands.run_reply,
274
+ ("run", "abandon"): commands.run_abandon,
275
+ ("user", "list"): commands.user_list,
276
+ ("user", "create"): commands.user_create,
277
+ ("role", "grant"): commands.role_grant,
278
+ ("role", "revoke"): commands.role_revoke,
279
+ ("tenant", "list"): commands.tenant_list,
280
+ ("tenant", "create"): commands.tenant_create,
281
+ ("connection", "list"): commands.connection_list,
282
+ ("connection", "create"): commands.connection_create,
283
+ ("connection", "bind"): commands.connection_bind,
284
+ }
285
+
286
+
287
+ def main(argv: list[str] | None = None, *, session: Session | None = None) -> int:
288
+ """Parse, then dispatch.
289
+
290
+ `session` is a seam for the tests and nothing else: it lets a test drive the real
291
+ parser and the real dispatch table over real HTTP without reaching for the machine's
292
+ keyring, which `build_session` would otherwise do (`store.py`).
293
+ """
294
+ parser = build_parser()
295
+ args = parser.parse_args(argv)
296
+ if args.command == "upgrade":
297
+ return _upgrade(args)
298
+ try:
299
+ session = session or build_session(args, allow_discovery=args.command == "login")
300
+ if args.command not in ("login", "logout"):
301
+ _advise_if_stale()
302
+ if args.command == "login":
303
+ return _login(session, args)
304
+ if args.command == "logout":
305
+ return _logout(session)
306
+ command = COMMANDS[(args.command, getattr(args, "subcommand", None))]
307
+ return command(ControlPlane(session=session), args)
308
+ except KeyboardInterrupt:
309
+ # Login (and device poll) wait on the human; Ctrl+C is a clean cancel, not a crash.
310
+ print("\nmkctl: cancelled", file=sys.stderr)
311
+ return 130
312
+ except StoreUnavailable as exc:
313
+ print(f"mkctl: {exc}", file=sys.stderr)
314
+ return 2
315
+ except (AuthorizationError, ControlPlaneError) as exc:
316
+ print(f"mkctl: {exc}", file=sys.stderr)
317
+ return 1
318
+ except httpx.HTTPError as exc:
319
+ print(f"mkctl: {exc}", file=sys.stderr)
320
+ return 2
321
+
322
+
323
+ def _login(session: Session, args: argparse.Namespace) -> int:
324
+ session.login(device=args.device, open_browser=not args.no_browser)
325
+ print(f"Signed in to {session.control_plane}")
326
+ _advise_from_platform(session.control_plane)
327
+ return 0
328
+
329
+
330
+ def _own_version() -> str:
331
+ return client.USER_AGENT.removeprefix("mkctl/")
332
+
333
+
334
+ def _advise_from_platform(control_plane: str) -> None:
335
+ """ADR 0014: one line, at login, best-effort — advice never fails a command."""
336
+ try:
337
+ response = httpx.get(
338
+ f"{control_plane.rstrip('/')}/v1/cli-config",
339
+ timeout=10,
340
+ headers={"User-Agent": client.USER_AGENT},
341
+ )
342
+ latest = response.json().get("cli", {}).get("latest", "")
343
+ except Exception:
344
+ # Advisory only: any failure here is silence, never a crash.
345
+ return
346
+ if not latest:
347
+ return
348
+ config.update(latest_seen=latest, advised_at=dt.datetime.now(dt.UTC).isoformat())
349
+ _print_advice_if_newer(latest)
350
+
351
+
352
+ def _advise_if_stale() -> None:
353
+ """Between logins: cached knowledge only, at most once a day, zero network."""
354
+ saved = config.load()
355
+ latest = saved.get("latest_seen", "")
356
+ if not latest:
357
+ return
358
+ advised = saved.get("advised_at", "")
359
+ try:
360
+ if advised and dt.datetime.now(dt.UTC) - dt.datetime.fromisoformat(advised) < dt.timedelta(
361
+ days=1
362
+ ):
363
+ return
364
+ except ValueError:
365
+ pass
366
+ if _print_advice_if_newer(latest):
367
+ config.update(advised_at=dt.datetime.now(dt.UTC).isoformat())
368
+
369
+
370
+ def _print_advice_if_newer(latest: str) -> bool:
371
+ from mkp_contracts import cli_versions
372
+
373
+ mine, theirs = cli_versions.parse(_own_version()), cli_versions.parse(latest)
374
+ if mine is None or theirs is None or mine >= theirs:
375
+ return False
376
+ print(
377
+ f"mkctl {_own_version()} — {latest} is available; run `mkctl upgrade` for the procedure",
378
+ file=sys.stderr,
379
+ )
380
+ return True
381
+
382
+
383
+ def _upgrade(args: argparse.Namespace) -> int:
384
+ """Print the procedure, perform nothing (ADR 0014, Amendment 1).
385
+
386
+ The index exists since 0.4.0, so the procedure is one line instead of four and
387
+ involves nobody's GitHub token. It still prints rather than performs, and that is
388
+ the amendment rather than an omission: an upgrade has to know *how* this was
389
+ installed — pipx, pip inside a virtualenv, `uv tool` — and a CLI that guesses
390
+ upgrades the wrong environment. Printing is also what keeps this command honest
391
+ when the answer is "you did not install it, your package manager did".
392
+ """
393
+ latest = config.load().get("latest_seen", "")
394
+ control_plane = (
395
+ args.control_plane
396
+ or os.environ.get(CONTROL_PLANE_ENV)
397
+ or config.load().get("control_plane")
398
+ )
399
+ if control_plane:
400
+ # Best-effort refresh; the cached answer is still an answer.
401
+ with suppress(httpx.HTTPError, ValueError, KeyError):
402
+ response = httpx.get(
403
+ f"{control_plane.rstrip('/')}/v1/cli-config",
404
+ timeout=10,
405
+ headers={"User-Agent": client.USER_AGENT},
406
+ )
407
+ latest = response.json().get("cli", {}).get("latest", latest)
408
+ print(f"mkctl {_own_version()} installed; latest declared: {latest or 'unknown'}")
409
+ print("\nUpgrade:")
410
+ print(f" pipx upgrade mkctl{f' # to {latest}' if latest else ''}")
411
+ print("\nor, if this was installed some other way:")
412
+ print(" pip install --upgrade mkctl")
413
+ return 0
414
+
415
+
416
+ def _logout(session: Session) -> int:
417
+ revoked = session.logout()
418
+ if revoked:
419
+ print("Token revoked and forgotten")
420
+ else:
421
+ # Worth distinguishing: the local copy is gone either way, but a token the
422
+ # provider never heard about being revoked is still live somewhere.
423
+ print("Local token forgotten; the provider did not confirm revocation", file=sys.stderr)
424
+ return 0
425
+
426
+
427
+ def _group(
428
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
429
+ name: str,
430
+ help_text: str,
431
+ ) -> argparse._SubParsersAction[argparse.ArgumentParser]:
432
+ """A noun with verbs under it: `mkctl run start`, `mkctl run reply`."""
433
+ parser = subparsers.add_parser(name, help=help_text)
434
+ return parser.add_subparsers(dest="subcommand", required=True)
435
+
436
+
437
+ def _target(parser: argparse.ArgumentParser, environment: bool = True) -> None:
438
+ """`--project` and `--env`, defaulted from the environment.
439
+
440
+ Required, and checked by argparse rather than by each command: a missing project is
441
+ a usage error and should read like one instead of arriving as a 404 for a path with
442
+ the word `None` in it.
443
+ """
444
+ parser.add_argument(
445
+ "--project",
446
+ default=os.environ.get(PROJECT_ENV),
447
+ required=PROJECT_ENV not in os.environ,
448
+ help=f"or ${PROJECT_ENV}",
449
+ )
450
+ if environment:
451
+ parser.add_argument(
452
+ "--env",
453
+ dest="environment",
454
+ default=os.environ.get(ENVIRONMENT_ENV),
455
+ required=ENVIRONMENT_ENV not in os.environ,
456
+ help=f"or ${ENVIRONMENT_ENV}",
457
+ )
458
+
459
+
460
+ def _role_arguments(parser: argparse.ArgumentParser) -> None:
461
+ """A user, a role, and the scope — which has no environment default on purpose."""
462
+ parser.add_argument("user", help="a user id, or the email of somebody in this tenant")
463
+ # Both the help and the accepted set come from `mkp_contracts.roles`, which is now
464
+ # the one place the four names live (ADR 0004). `choices=` was the wrong call while
465
+ # this list was a local copy — a fourth copy of a name list is what `prod pin` had to
466
+ # be fixed for — and is the right one now that there is nothing to drift from: a
467
+ # wrong role fails in the terminal, naming the four, instead of at the API.
468
+ parser.add_argument("role", choices=ROLES, help=f"{', '.join(ROLES)} (ADR 0004)")
469
+ parser.add_argument("--project", help="scope the role to one project")
470
+ parser.add_argument(
471
+ "--env",
472
+ dest="environment",
473
+ help="scope it to one environment; needs --project, since names repeat across them",
474
+ )
475
+
476
+
477
+ if __name__ == "__main__":
478
+ raise SystemExit(main())
mkctl/client.py ADDED
@@ -0,0 +1,131 @@
1
+ """Driving the control plane with a login that is already held.
2
+
3
+ One place adds the bearer, and one place turns a refusal into something a person can
4
+ read. Both matter more than they look:
5
+
6
+ * the token comes from `Session.access_token()`, which refreshes when it is due — so a
7
+ long-running command cannot be the thing that discovers the session expired, and no
8
+ command has its own idea of where a token lives (ADR 0004: the keyring, and nowhere
9
+ else).
10
+ * the control plane's refusals are structured. A deploy rejected by the deploy rules
11
+ answers with `{"detail": {"rejected": [...]}}` because ADR 0005 requires the reason to
12
+ name the constraint, and a CLI that printed that dictionary as Python would throw the
13
+ reason away at the last step of the journey it was written for.
14
+
15
+ The one thing this deliberately does not do is compute a bundle's digest. The control
16
+ plane recomputes it from the manifest and returns it (`routes.py:_verify_digest`), so the
17
+ archive is uploaded under the name the server gave it. A second opinion here would be a
18
+ second definition of a bundle's identity, which is the thing `mkp-contracts` exists to
19
+ prevent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib.metadata
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ import httpx
29
+
30
+ from .session import Session
31
+
32
+ TIMEOUT = 60
33
+
34
+
35
+ class ControlPlaneError(Exception):
36
+ """The control plane refused, and said why."""
37
+
38
+ def __init__(self, status_code: int, message: str) -> None:
39
+ super().__init__(message)
40
+ self.status_code = status_code
41
+
42
+
43
+ # ADR 0014: the CLI names itself on every request. Not trusted, not authentication —
44
+ # it is what lets the control plane answer "upgrade" instead of a validation error.
45
+ USER_AGENT = f"mkctl/{importlib.metadata.version('mkctl')}"
46
+
47
+
48
+ @dataclass
49
+ class ControlPlane:
50
+ """Authenticated requests against one control plane."""
51
+
52
+ session: Session
53
+ client: httpx.Client | None = None
54
+ _token: str | None = field(default=None, init=False, repr=False)
55
+
56
+ @property
57
+ def base_url(self) -> str:
58
+ return self.session.control_plane.rstrip("/")
59
+
60
+ def get(self, path: str, **kwargs: Any) -> Any:
61
+ return self.request("GET", path, **kwargs)
62
+
63
+ def post(self, path: str, **kwargs: Any) -> Any:
64
+ return self.request("POST", path, **kwargs)
65
+
66
+ def put(self, path: str, **kwargs: Any) -> Any:
67
+ return self.request("PUT", path, **kwargs)
68
+
69
+ def delete(self, path: str, **kwargs: Any) -> Any:
70
+ return self.request("DELETE", path, **kwargs)
71
+
72
+ def request(
73
+ self,
74
+ method: str,
75
+ path: str,
76
+ *,
77
+ json: Any = None,
78
+ content: bytes | None = None,
79
+ params: dict[str, Any] | None = None,
80
+ ) -> Any:
81
+ if self._token is None:
82
+ self._token = self.session.access_token()
83
+ headers = {"Authorization": f"Bearer {self._token}", "User-Agent": USER_AGENT}
84
+ request = {"json": json, "content": content, "params": params, "headers": headers}
85
+ if self.client is not None:
86
+ response = self.client.request(method, f"{self.base_url}{path}", **request)
87
+ else:
88
+ with httpx.Client(timeout=TIMEOUT) as http:
89
+ response = http.request(method, f"{self.base_url}{path}", **request)
90
+ if response.status_code >= 400:
91
+ raise _refusal(response)
92
+ if response.status_code == 204 or not response.content:
93
+ return None
94
+ return response.json()
95
+
96
+
97
+ def _refusal(response: httpx.Response) -> ControlPlaneError:
98
+ if response.status_code == 426:
99
+ # ADR 0014: the platform stopped serving this mkctl. The detail already
100
+ # names the versions and the command; pass it through untouched.
101
+ return ControlPlaneError(426, _reason(response))
102
+ if response.status_code == 401:
103
+ # Distinguished from every other refusal because the remedy is different: a 403
104
+ # is "you may not", and only this one is "you are nobody yet".
105
+ return ControlPlaneError(401, "the control plane rejected this token — run `mkctl login`")
106
+ return ControlPlaneError(response.status_code, _reason(response))
107
+
108
+
109
+ def _reason(response: httpx.Response) -> str:
110
+ try:
111
+ body = response.json()
112
+ except ValueError:
113
+ return response.text.strip() or f"HTTP {response.status_code}"
114
+
115
+ detail = body.get("detail", body) if isinstance(body, dict) else body
116
+ if isinstance(detail, dict) and "rejected" in detail:
117
+ # The deploy rules, the trigger registration and `D4` all answer this way. Each
118
+ # entry names one constraint, and all of them are the answer — printing only the
119
+ # first would send somebody back for a second attempt that fails on the second.
120
+ reasons = detail["rejected"]
121
+ return "refused:\n" + "\n".join(f" - {reason}" for reason in reasons)
122
+ if isinstance(detail, list):
123
+ # FastAPI's own validation errors, which is what a mistyped input looks like.
124
+ return "; ".join(
125
+ f"{'.'.join(str(part) for part in item.get('loc', [])[1:])}: {item.get('msg', '')}"
126
+ for item in detail
127
+ )
128
+ return str(detail)
129
+
130
+
131
+ __all__ = ["ControlPlane", "ControlPlaneError"]