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/commands.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""What each command does, once there is a login to do it with.
|
|
2
|
+
|
|
3
|
+
`C14`. Every function here is one or two calls against endpoints that already existed:
|
|
4
|
+
this is the CLI catching up with the API, not new platform behaviour. Where a command
|
|
5
|
+
looks like it is doing something clever it is doing the opposite — resolving a name the
|
|
6
|
+
caller knows into an id the API wants, and nothing else.
|
|
7
|
+
|
|
8
|
+
Two decisions worth stating, because both could reasonably have gone the other way.
|
|
9
|
+
|
|
10
|
+
**A bundle is pushed, not built.** `mkbundle build` produces the archive and this uploads
|
|
11
|
+
it. Building needs a connector catalog, `mklang check` on every machine, and a signing
|
|
12
|
+
key; folding that in would make one command that fails for eight unrelated reasons and
|
|
13
|
+
would put a signing key in the same process as a login token.
|
|
14
|
+
|
|
15
|
+
**Inputs are strings unless the caller says otherwise.** `--input thread_id=t-1` sends a
|
|
16
|
+
string, and `--input-json` is how anything else is expressed. A shell word carries no
|
|
17
|
+
type, so guessing one — `count=2` as a number here and a string somewhere else — would
|
|
18
|
+
make the same command mean two things depending on what the value looked like.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from mkp_contracts.archive import manifest_of
|
|
30
|
+
from mkp_contracts.errors import ContractError
|
|
31
|
+
|
|
32
|
+
from .client import ControlPlane, ControlPlaneError
|
|
33
|
+
|
|
34
|
+
DASH = "-"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scope_of(binding: dict) -> str:
|
|
38
|
+
"""Where a binding applies, as one readable phrase."""
|
|
39
|
+
if binding.get("environment"):
|
|
40
|
+
return f"{binding['project']}/{binding['environment']}"
|
|
41
|
+
if binding.get("project"):
|
|
42
|
+
return f"project {binding['project']}"
|
|
43
|
+
return "the whole tenant"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Who am I.
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def whoami(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
52
|
+
body = plane.get("/v1/me")
|
|
53
|
+
if not _as_json(args, body):
|
|
54
|
+
print(f"principal {body['principal_id']} ({body['kind']})")
|
|
55
|
+
print(f"tenant {body['tenant_id']}")
|
|
56
|
+
# `roles` would print the same line for a role over the whole tenant and a role
|
|
57
|
+
# over one project, and the second one is refused by every tenant-scoped endpoint
|
|
58
|
+
# (`D14`). One line per binding, with where it applies.
|
|
59
|
+
for binding in body.get("bindings") or []:
|
|
60
|
+
print(f"role {binding['role']} on {_scope_of(binding)}")
|
|
61
|
+
if not body.get("roles"):
|
|
62
|
+
# Being authenticated is not being authorized, and a user with no binding can
|
|
63
|
+
# do nothing at all. Saying so here saves a confusing round of 403s.
|
|
64
|
+
_warn("no role binding: you are authenticated but not authorized for anything yet")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Bundles and the pointer.
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bundle_push(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
74
|
+
"""Register the manifest, then upload the bytes.
|
|
75
|
+
|
|
76
|
+
Two calls because they are two facts (`routes.py`), and in this order because the
|
|
77
|
+
upload is refused for a bundle nobody registered. The digest is the server's answer
|
|
78
|
+
to the first call — see `client.py` for why this does not compute its own.
|
|
79
|
+
"""
|
|
80
|
+
archive = Path(args.archive)
|
|
81
|
+
payload = archive.read_bytes()
|
|
82
|
+
try:
|
|
83
|
+
manifest = manifest_of(payload)
|
|
84
|
+
except (ContractError, KeyError, ValueError) as exc:
|
|
85
|
+
raise ControlPlaneError(0, f"{archive}: not a bundle archive — {exc}") from exc
|
|
86
|
+
|
|
87
|
+
registered = plane.post(f"/v1/projects/{args.project}/bundles", json={"manifest": manifest})
|
|
88
|
+
digest = registered["digest"]
|
|
89
|
+
plane.put(f"/v1/projects/{args.project}/bundles/{digest}/archive", content=payload)
|
|
90
|
+
|
|
91
|
+
if not _as_json(args, {"digest": digest, "project": args.project, "bytes": len(payload)}):
|
|
92
|
+
print(f"registered {digest}")
|
|
93
|
+
print(f"uploaded {_size(len(payload))}")
|
|
94
|
+
signature = manifest.get("signature")
|
|
95
|
+
# Not an error here: the control plane accepts an unsigned bundle and the runner
|
|
96
|
+
# refuses one (`D2`). Saying so at push time is the difference between finding out
|
|
97
|
+
# now and finding out one poll later, on a runner, as a run that will not start.
|
|
98
|
+
if not signature:
|
|
99
|
+
_warn("this bundle is unsigned, and a runner refuses what it cannot verify (D2)")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def deploy(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
104
|
+
body = plane.post(
|
|
105
|
+
f"/v1/projects/{args.project}/environments/{args.environment}/deployments",
|
|
106
|
+
json={"bundle_digest": args.digest},
|
|
107
|
+
)
|
|
108
|
+
if not _as_json(args, body):
|
|
109
|
+
print(f"deployed {_short(args.digest)} to {args.project}/{args.environment}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Projects and environments.
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def project_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
119
|
+
rows = plane.get("/v1/projects")
|
|
120
|
+
if not _as_json(args, rows):
|
|
121
|
+
_table(["PROJECT", "ID"], [[row["slug"], row["id"]] for row in rows])
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def project_create(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
126
|
+
body = plane.post("/v1/projects", json={"slug": args.slug})
|
|
127
|
+
if not _as_json(args, body):
|
|
128
|
+
print(f"created project {body['slug']}")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def environment_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
133
|
+
rows = plane.get(f"/v1/projects/{args.project}/environments")
|
|
134
|
+
if not _as_json(args, rows):
|
|
135
|
+
_table(
|
|
136
|
+
["ENVIRONMENT", "DEPLOYED"],
|
|
137
|
+
[[row["name"], _short(row["bundle_digest"])] for row in rows],
|
|
138
|
+
)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def environment_create(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
143
|
+
body = plane.post(f"/v1/projects/{args.project}/environments", json={"name": args.name})
|
|
144
|
+
if not _as_json(args, body):
|
|
145
|
+
print(f"created environment {args.project}/{body['name']}, pointing at nothing")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def environment_get(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
150
|
+
body = plane.get(f"/v1/projects/{args.project}/environments/{args.name}")
|
|
151
|
+
if not _as_json(args, body):
|
|
152
|
+
print(f"environment {args.project}/{body['environment']}")
|
|
153
|
+
print(f"bundle {body['bundle_digest'] or DASH}")
|
|
154
|
+
print(f"config {body['config_digest'] or DASH}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Runs, including the one that is waiting.
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_start(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
164
|
+
inputs = _inputs(args)
|
|
165
|
+
body = plane.post(
|
|
166
|
+
f"/v1/projects/{args.project}/environments/{args.environment}/runs",
|
|
167
|
+
json={"workflow": args.workflow, "inputs": inputs},
|
|
168
|
+
)
|
|
169
|
+
if not _as_json(args, body):
|
|
170
|
+
print(f"run {body['id']}")
|
|
171
|
+
print(f"workflow {body['workflow']} on {_short(body['bundle_digest'])}")
|
|
172
|
+
print(f"status {body['status']}")
|
|
173
|
+
# Nothing executes in that request (ADR 0002), and a person who has just been
|
|
174
|
+
# handed a run id will otherwise read `pending` as a failure to start.
|
|
175
|
+
print("\nNothing runs in this request: a runner picks it up on its next poll.")
|
|
176
|
+
# `D16`: the API says at creation when that poll is never coming.
|
|
177
|
+
for warning in body.get("warnings", []):
|
|
178
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_get(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
183
|
+
body = plane.get(f"/v1/runs/{args.run_id}")
|
|
184
|
+
if not _as_json(args, body):
|
|
185
|
+
print(f"run {body['id']}")
|
|
186
|
+
print(f"workflow {body['workflow']}")
|
|
187
|
+
if body.get("project") and body.get("environment"):
|
|
188
|
+
print(f"where {body['project']}/{body['environment']}")
|
|
189
|
+
print(f"status {body['status']}")
|
|
190
|
+
if body.get("error"):
|
|
191
|
+
print(f"error {body['error']}")
|
|
192
|
+
waiting = body.get("waiting_for")
|
|
193
|
+
if waiting:
|
|
194
|
+
summary = waiting.get("summary") or f"step {waiting['step_id']}"
|
|
195
|
+
print(f"waiting {summary} since {waiting['since']}")
|
|
196
|
+
pending = body.get("pending_reply")
|
|
197
|
+
if pending and pending.get("kind") == "abandon":
|
|
198
|
+
print("pending abandon (runner not yet cancelled the durable run)")
|
|
199
|
+
elif pending:
|
|
200
|
+
print(f"pending reply at {pending['step_id']} (runner not yet delivered)")
|
|
201
|
+
else:
|
|
202
|
+
print(f"\nAnswer: mkctl run reply {body['id']} --text '…'")
|
|
203
|
+
print(f"Abandon: mkctl run abandon {body['id']}")
|
|
204
|
+
if body["steps"]:
|
|
205
|
+
print()
|
|
206
|
+
_table(
|
|
207
|
+
["STEP", "TRY", "STATUS", "ERROR"],
|
|
208
|
+
[
|
|
209
|
+
[
|
|
210
|
+
step["step_id"],
|
|
211
|
+
str(step["attempt"]),
|
|
212
|
+
step["status"],
|
|
213
|
+
step["error_code"] or DASH,
|
|
214
|
+
]
|
|
215
|
+
for step in body["steps"]
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def run_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
222
|
+
"""Cursor-paginated runs (`GET /v1/runs`, C25)."""
|
|
223
|
+
params: dict[str, str] = {}
|
|
224
|
+
if args.status:
|
|
225
|
+
params["status"] = args.status
|
|
226
|
+
if args.project:
|
|
227
|
+
params["project"] = args.project
|
|
228
|
+
if args.environment:
|
|
229
|
+
params["environment"] = args.environment
|
|
230
|
+
if args.workflow:
|
|
231
|
+
params["workflow"] = args.workflow
|
|
232
|
+
if args.limit is not None:
|
|
233
|
+
params["limit"] = str(args.limit)
|
|
234
|
+
if args.cursor:
|
|
235
|
+
params["cursor"] = args.cursor
|
|
236
|
+
query = "&".join(f"{k}={v}" for k, v in params.items())
|
|
237
|
+
path = f"/v1/runs?{query}" if query else "/v1/runs"
|
|
238
|
+
body = plane.get(path)
|
|
239
|
+
if not _as_json(args, body):
|
|
240
|
+
items = body.get("items") or []
|
|
241
|
+
_table(
|
|
242
|
+
["ID", "STATUS", "WORKFLOW", "WHERE", "WAITING"],
|
|
243
|
+
[
|
|
244
|
+
[
|
|
245
|
+
row["id"],
|
|
246
|
+
row["status"],
|
|
247
|
+
row["workflow"],
|
|
248
|
+
f"{row.get('project') or '?'}/{row.get('environment') or '?'}",
|
|
249
|
+
(row.get("waiting_for") or {}).get("summary")
|
|
250
|
+
or (row.get("waiting_for") or {}).get("step_id")
|
|
251
|
+
or DASH,
|
|
252
|
+
]
|
|
253
|
+
for row in items
|
|
254
|
+
],
|
|
255
|
+
)
|
|
256
|
+
if body.get("next_cursor"):
|
|
257
|
+
print(f"\nnext page: mkctl run list --cursor {body['next_cursor']}")
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def run_reply(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
262
|
+
"""Answer a waiting run.
|
|
263
|
+
|
|
264
|
+
`--text` covers what an escalation actually asks for — a sentence a machine reads as
|
|
265
|
+
prose — and `--json-payload` is there because the gate's answer is whatever the
|
|
266
|
+
workflow reads, not necessarily a string.
|
|
267
|
+
"""
|
|
268
|
+
payload: Any = args.text
|
|
269
|
+
if args.json_payload is not None:
|
|
270
|
+
try:
|
|
271
|
+
payload = json.loads(args.json_payload)
|
|
272
|
+
except json.JSONDecodeError as exc:
|
|
273
|
+
raise ControlPlaneError(0, f"--json-payload is not JSON: {exc}") from exc
|
|
274
|
+
request: dict[str, Any] = {"payload": payload}
|
|
275
|
+
if args.step is not None:
|
|
276
|
+
request["step_id"] = args.step
|
|
277
|
+
|
|
278
|
+
body = plane.post(f"/v1/runs/{args.run_id}/reply", json=request)
|
|
279
|
+
if not _as_json(args, body):
|
|
280
|
+
print(f"answered {body['run_id']} at step {body['step_id']}")
|
|
281
|
+
# The reply is a row and the control plane holds no runner address (ADR 0002),
|
|
282
|
+
# so "answered" is not "the durable run woke" and saying otherwise would be a
|
|
283
|
+
# lie a person would then wait on.
|
|
284
|
+
print("The runner collects it on its next poll.")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def run_abandon(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
289
|
+
"""Abandon a waiting run (`POST /v1/runs/{id}/abandon`, C28)."""
|
|
290
|
+
request: dict[str, Any] = {}
|
|
291
|
+
if args.reason:
|
|
292
|
+
request["reason"] = args.reason
|
|
293
|
+
body = plane.post(
|
|
294
|
+
f"/v1/runs/{args.run_id}/abandon",
|
|
295
|
+
json=request if request else {},
|
|
296
|
+
)
|
|
297
|
+
if not _as_json(args, body):
|
|
298
|
+
print(f"abandon queued for {body['run_id']} at step {body['step_id']}")
|
|
299
|
+
print("The runner cancels the durable workflow on its next poll.")
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Who is in a tenant, and what they may do.
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def user_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
309
|
+
rows = plane.get("/v1/users")
|
|
310
|
+
if not _as_json(args, rows):
|
|
311
|
+
_table(
|
|
312
|
+
["EMAIL", "SUBJECT", "STATUS", "ROLES"],
|
|
313
|
+
[
|
|
314
|
+
[
|
|
315
|
+
row["email"],
|
|
316
|
+
row["external_subject"],
|
|
317
|
+
row["status"],
|
|
318
|
+
# Role *and* scope: "who can deploy" is the question this table is
|
|
319
|
+
# read for, and `maintainer` alone does not answer it (`D14`).
|
|
320
|
+
"; ".join(
|
|
321
|
+
f"{binding['role']} on {_scope_of(binding)}"
|
|
322
|
+
for binding in row.get("bindings") or []
|
|
323
|
+
)
|
|
324
|
+
or DASH,
|
|
325
|
+
]
|
|
326
|
+
for row in rows
|
|
327
|
+
],
|
|
328
|
+
)
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def user_create(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
333
|
+
body = plane.post("/v1/users", json={"external_subject": args.subject, "email": args.email})
|
|
334
|
+
if not _as_json(args, body):
|
|
335
|
+
print(f"created user {body['email']} ({body['id']})")
|
|
336
|
+
# Membership and permission are two decisions in the API (`routes_resources.py`),
|
|
337
|
+
# and a user created here can do nothing until the second one is made.
|
|
338
|
+
print("No roles yet: grant one with `mkctl role grant`.")
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def role_grant(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
343
|
+
user_id = _resolve_user(plane, args.user)
|
|
344
|
+
body: dict[str, Any] = {"role": args.role}
|
|
345
|
+
if args.project is not None:
|
|
346
|
+
body["project"] = args.project
|
|
347
|
+
if args.environment is not None:
|
|
348
|
+
body["environment"] = args.environment
|
|
349
|
+
plane.put(f"/v1/users/{user_id}/roles", json=body)
|
|
350
|
+
if not _as_json(args, {"user_id": user_id, **body}):
|
|
351
|
+
print(f"granted {args.role} to {args.user} at {_scope(args)}")
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def role_revoke(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
356
|
+
user_id = _resolve_user(plane, args.user)
|
|
357
|
+
params = {
|
|
358
|
+
name: value
|
|
359
|
+
for name, value in (("project", args.project), ("environment", args.environment))
|
|
360
|
+
if value is not None
|
|
361
|
+
}
|
|
362
|
+
plane.delete(f"/v1/users/{user_id}/roles/{args.role}", params=params or None)
|
|
363
|
+
if not _as_json(args, {"user_id": user_id, "role": args.role, **params}):
|
|
364
|
+
print(f"revoked {args.role} from {args.user} at {_scope(args)}")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Tenants, which is the one thing that acts above a tenant.
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def tenant_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
374
|
+
rows = plane.get("/v1/tenants")
|
|
375
|
+
if not _as_json(args, rows):
|
|
376
|
+
_table(
|
|
377
|
+
["TENANT", "STATUS", "CREATED"],
|
|
378
|
+
[[row["slug"], row["status"], row["created_at"][:10]] for row in rows],
|
|
379
|
+
)
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def tenant_create(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
384
|
+
"""Create a tenant and its first owner.
|
|
385
|
+
|
|
386
|
+
Needs the platform grant, which is not a role: `mkdb grant-platform-admin` writes it
|
|
387
|
+
and `deploy/runbooks/onboarding.md` is the procedure. An operator who owns everything
|
|
388
|
+
inside a tenant still gets a 403 here, which is the point of the separation.
|
|
389
|
+
"""
|
|
390
|
+
payload: dict = {
|
|
391
|
+
"slug": args.slug,
|
|
392
|
+
"owner": {"external_subject": args.owner_subject, "email": args.owner_email},
|
|
393
|
+
}
|
|
394
|
+
if args.monthly_run_limit is not None:
|
|
395
|
+
payload["monthly_run_limit"] = args.monthly_run_limit
|
|
396
|
+
if args.monthly_token_limit is not None:
|
|
397
|
+
payload["monthly_token_limit"] = args.monthly_token_limit
|
|
398
|
+
body = plane.post("/v1/tenants", json=payload)
|
|
399
|
+
if not _as_json(args, body):
|
|
400
|
+
owner = body["owner"]
|
|
401
|
+
print(f"created tenant {body['slug']} ({body['id']})")
|
|
402
|
+
print(f"owner {owner['email']} — {', '.join(owner['roles'])}")
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def connection_list(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
407
|
+
rows = plane.get("/v1/connections")
|
|
408
|
+
if not _as_json(args, rows):
|
|
409
|
+
_table(
|
|
410
|
+
["LABEL", "CONNECTOR", "STATUS"],
|
|
411
|
+
[[row["label"], row["connector"], row["status"]] for row in rows],
|
|
412
|
+
)
|
|
413
|
+
return 0
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def connection_create(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
417
|
+
"""Store a static credential, or an authless row (`D10`) when material is omitted.
|
|
418
|
+
|
|
419
|
+
Material is always a map of strings from `--material name=value`. No pairs means
|
|
420
|
+
empty material — the honest shape for `auth: none` connectors that still need a
|
|
421
|
+
bound name for deploy rules.
|
|
422
|
+
"""
|
|
423
|
+
material: dict[str, str] = {}
|
|
424
|
+
for pair in args.material or []:
|
|
425
|
+
name, separator, value = pair.partition("=")
|
|
426
|
+
if not separator:
|
|
427
|
+
raise ControlPlaneError(400, f"--material expects name=value, got {pair!r} (no '=')")
|
|
428
|
+
material[name] = value
|
|
429
|
+
body = plane.post(
|
|
430
|
+
"/v1/connections",
|
|
431
|
+
json={"connector": args.connector, "label": args.label, "material": material},
|
|
432
|
+
)
|
|
433
|
+
if not _as_json(args, body):
|
|
434
|
+
kind = "authless" if not material else "static"
|
|
435
|
+
print(f"created connection {body['label']} ({body['id']}) — {kind}, {body['status']}")
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def connection_bind(plane: ControlPlane, args: argparse.Namespace) -> int:
|
|
440
|
+
"""Name what a workflow's connection: line means in one environment."""
|
|
441
|
+
plane.put(
|
|
442
|
+
f"/v1/projects/{args.project}/environments/{args.environment}/connections/{args.name}",
|
|
443
|
+
json={"connection_id": args.connection_id},
|
|
444
|
+
)
|
|
445
|
+
if not getattr(args, "json", False):
|
|
446
|
+
print(f"bound {args.name} → {args.connection_id} in {args.project}/{args.environment}")
|
|
447
|
+
return 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# Helpers.
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _resolve_user(plane: ControlPlane, who: str) -> str:
|
|
456
|
+
"""A uuid, or the email of somebody in this tenant.
|
|
457
|
+
|
|
458
|
+
A person knows the address they invited; the id is in a response they have already
|
|
459
|
+
closed. Resolving here rather than making them look it up is the same argument
|
|
460
|
+
`RoleIn` makes about scopes — the lookup is what an API is for.
|
|
461
|
+
"""
|
|
462
|
+
if "@" not in who:
|
|
463
|
+
return who
|
|
464
|
+
for user in plane.get("/v1/users"):
|
|
465
|
+
if user["email"] == who:
|
|
466
|
+
return str(user["id"])
|
|
467
|
+
raise ControlPlaneError(404, f"no user with the address {who} in this tenant")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _inputs(args: argparse.Namespace) -> dict[str, Any]:
|
|
471
|
+
inputs: dict[str, Any] = {}
|
|
472
|
+
for pair in args.input or []:
|
|
473
|
+
name, separator, value = pair.partition("=")
|
|
474
|
+
if not separator:
|
|
475
|
+
raise ControlPlaneError(0, f"--input takes name=value, not {pair!r}")
|
|
476
|
+
inputs[name] = value
|
|
477
|
+
if args.input_json is not None:
|
|
478
|
+
try:
|
|
479
|
+
decoded = json.loads(args.input_json)
|
|
480
|
+
except json.JSONDecodeError as exc:
|
|
481
|
+
raise ControlPlaneError(0, f"--input-json is not JSON: {exc}") from exc
|
|
482
|
+
if not isinstance(decoded, dict):
|
|
483
|
+
raise ControlPlaneError(0, "--input-json must be a JSON object of workflow inputs")
|
|
484
|
+
inputs.update(decoded)
|
|
485
|
+
return inputs
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _scope(args: argparse.Namespace) -> str:
|
|
489
|
+
if args.environment is not None:
|
|
490
|
+
return f"{args.project}/{args.environment}"
|
|
491
|
+
if args.project is not None:
|
|
492
|
+
return f"project {args.project}"
|
|
493
|
+
return "the tenant"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _as_json(args: argparse.Namespace, payload: Any) -> bool:
|
|
497
|
+
if not getattr(args, "json", False):
|
|
498
|
+
return False
|
|
499
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _table(headers: list[str], rows: list[list[str]]) -> None:
|
|
504
|
+
if not rows:
|
|
505
|
+
print("(none)")
|
|
506
|
+
return
|
|
507
|
+
widths = [
|
|
508
|
+
max(len(headers[column]), *(len(row[column]) for row in rows))
|
|
509
|
+
for column in range(len(headers))
|
|
510
|
+
]
|
|
511
|
+
print(" ".join(header.ljust(widths[i]) for i, header in enumerate(headers)).rstrip())
|
|
512
|
+
for row in rows:
|
|
513
|
+
print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _short(digest: str | None) -> str:
|
|
517
|
+
return digest[:12] if digest else DASH
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _size(count: int) -> str:
|
|
521
|
+
if count < 1024:
|
|
522
|
+
return f"{count} bytes"
|
|
523
|
+
return f"{count / 1024:.1f} KiB"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _warn(message: str) -> None:
|
|
527
|
+
print(f"\n{message}", file=sys.stderr)
|
mkctl/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Where `mkctl` remembers how to reach a platform (`C32`).
|
|
2
|
+
|
|
3
|
+
One file, three values, and a strict precedence: an explicit flag beats the
|
|
4
|
+
environment, the environment beats this file, and this file beats asking the control
|
|
5
|
+
plane. Only *discovered* values are ever written here — what a person set in a flag
|
|
6
|
+
or a shell profile is theirs to manage, and a tool that copies it into a second
|
|
7
|
+
location is a tool that creates drift between two places that both look
|
|
8
|
+
authoritative. The day this module was written, the issuer and client id had changed
|
|
9
|
+
under every shell profile that had them: discovery is the fix, this file is only its
|
|
10
|
+
cache.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def path() -> Path:
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
23
|
+
return Path(base) / "mkctl" / "config.toml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load() -> dict[str, str]:
|
|
27
|
+
try:
|
|
28
|
+
raw = path().read_bytes()
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
data = tomllib.loads(raw.decode())
|
|
33
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
34
|
+
# A corrupt cache is an empty cache, never a crash: everything in it can be
|
|
35
|
+
# re-discovered from the control plane it points at.
|
|
36
|
+
return {}
|
|
37
|
+
return {k: v for k, v in data.items() if isinstance(v, str)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save(control_plane: str, issuer: str, client_id: str) -> None:
|
|
41
|
+
update(control_plane=control_plane, issuer=issuer, client_id=client_id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def update(**values: str) -> None:
|
|
45
|
+
"""Merge into the file, keeping what is already there.
|
|
46
|
+
|
|
47
|
+
One writer shape for every key — the discovered endpoints and the advisory
|
|
48
|
+
bookkeeping (ADR 0014: `latest_seen`, `advised_at`) share the file because they
|
|
49
|
+
share the nature: facts the platform declared, safe to lose, re-learned on the
|
|
50
|
+
next login.
|
|
51
|
+
"""
|
|
52
|
+
merged = {**load(), **values}
|
|
53
|
+
target = path()
|
|
54
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
lines = "".join(f'{key} = "{value}"\n' for key, value in sorted(merged.items()))
|
|
56
|
+
target.write_text(
|
|
57
|
+
"# Written by `mkctl login` from GET /v1/cli-config — a cache of what the\n"
|
|
58
|
+
"# control plane declared, safe to delete: login discovers it again.\n" + lines
|
|
59
|
+
)
|