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 +8 -0
- mkctl/cli.py +478 -0
- mkctl/client.py +131 -0
- mkctl/commands.py +527 -0
- mkctl/config.py +59 -0
- mkctl/oauth.py +398 -0
- mkctl/py.typed +0 -0
- mkctl/session.py +104 -0
- mkctl/store.py +90 -0
- mkctl-0.4.0.dist-info/METADATA +10 -0
- mkctl-0.4.0.dist-info/RECORD +14 -0
- mkctl-0.4.0.dist-info/WHEEL +4 -0
- mkctl-0.4.0.dist-info/entry_points.txt +2 -0
- mkctl-0.4.0.dist-info/licenses/LICENSE +201 -0
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"]
|