mkbundle 0.2.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.
- mkbundle/__init__.py +12 -0
- mkbundle/build.py +286 -0
- mkbundle/catalog.py +48 -0
- mkbundle/cli.py +202 -0
- mkbundle/errors.py +17 -0
- mkbundle/py.typed +0 -0
- mkbundle/rules.py +417 -0
- mkbundle/signing.py +87 -0
- mkbundle/source.py +107 -0
- mkbundle/toolchain.py +86 -0
- mkbundle-0.2.0.dist-info/METADATA +9 -0
- mkbundle-0.2.0.dist-info/RECORD +15 -0
- mkbundle-0.2.0.dist-info/WHEEL +4 -0
- mkbundle-0.2.0.dist-info/entry_points.txt +2 -0
- mkbundle-0.2.0.dist-info/licenses/LICENSE +201 -0
mkbundle/rules.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""The build-time cross-file rules.
|
|
2
|
+
|
|
3
|
+
The schemas cannot express these: each needs context beyond one document
|
|
4
|
+
(`schema/README.md`). The rules that need *tenant* state — budgets against a
|
|
5
|
+
tenant's constraints, connections against a target environment — are deploy-time
|
|
6
|
+
checks and deliberately absent here. A builder has no tenant.
|
|
7
|
+
|
|
8
|
+
The one that carries the most weight is `missing-idempotency-key`. Durable
|
|
9
|
+
execution is at-least-once for external effects (ADR 0009 Amendment 1), so the
|
|
10
|
+
idempotency key is what makes an effect happen once. A write step without one is
|
|
11
|
+
not a style problem, it is a duplicate email waiting for the first crash.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from mkp_contracts import expressions, gates
|
|
22
|
+
|
|
23
|
+
from .catalog import Connector
|
|
24
|
+
from .errors import Report
|
|
25
|
+
|
|
26
|
+
SECRET_REF_RE = re.compile(r"\{\{\s*secrets\.([a-zA-Z_][a-zA-Z0-9_]*)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def walk_steps(workflow: dict[str, Any]) -> list[tuple[dict[str, Any], str]]:
|
|
30
|
+
"""Every step in a workflow, including those nested in branches and foreach.
|
|
31
|
+
|
|
32
|
+
Returns each step with a location, so a failure points at the step and not
|
|
33
|
+
merely at the file.
|
|
34
|
+
"""
|
|
35
|
+
found: list[tuple[dict[str, Any], str]] = []
|
|
36
|
+
|
|
37
|
+
def visit(steps: Any, prefix: str) -> None:
|
|
38
|
+
if not isinstance(steps, list):
|
|
39
|
+
return
|
|
40
|
+
for index, step in enumerate(steps):
|
|
41
|
+
if not isinstance(step, dict):
|
|
42
|
+
continue
|
|
43
|
+
location = f"{prefix}[{index}]"
|
|
44
|
+
found.append((step, location))
|
|
45
|
+
visit(step.get("steps"), f"{location}.steps")
|
|
46
|
+
for case_index, case in enumerate(step.get("cases") or []):
|
|
47
|
+
if isinstance(case, dict):
|
|
48
|
+
visit(case.get("steps"), f"{location}.cases[{case_index}].steps")
|
|
49
|
+
visit(step.get("default"), f"{location}.default")
|
|
50
|
+
|
|
51
|
+
visit(workflow.get("steps"), "steps")
|
|
52
|
+
visit(workflow.get("on_failure"), "on_failure")
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def secret_refs(document: Any) -> set[str]:
|
|
57
|
+
"""secretRefs a workflow uses: `verify` on a webhook trigger, and expressions."""
|
|
58
|
+
found: set[str] = set()
|
|
59
|
+
|
|
60
|
+
def visit(node: Any, key: str | None) -> None:
|
|
61
|
+
if isinstance(node, dict):
|
|
62
|
+
for child_key, value in node.items():
|
|
63
|
+
visit(value, child_key)
|
|
64
|
+
elif isinstance(node, list):
|
|
65
|
+
for value in node:
|
|
66
|
+
visit(value, key)
|
|
67
|
+
elif isinstance(node, str):
|
|
68
|
+
if key == "verify":
|
|
69
|
+
found.add(node)
|
|
70
|
+
found.update(SECRET_REF_RE.findall(node))
|
|
71
|
+
|
|
72
|
+
visit(document, None)
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def connections(workflow: dict[str, Any]) -> set[str]:
|
|
77
|
+
used = {
|
|
78
|
+
trigger["connection"]
|
|
79
|
+
for trigger in workflow.get("triggers") or []
|
|
80
|
+
if isinstance(trigger, dict) and trigger.get("connection")
|
|
81
|
+
}
|
|
82
|
+
used.update(step["connection"] for step, _ in walk_steps(workflow) if step.get("connection"))
|
|
83
|
+
return used
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_workflow(
|
|
87
|
+
workflow: dict[str, Any],
|
|
88
|
+
path: str,
|
|
89
|
+
*,
|
|
90
|
+
machines: set[str],
|
|
91
|
+
declared_connections: set[str],
|
|
92
|
+
declared_secrets: set[str],
|
|
93
|
+
connector_pins: dict[str, int],
|
|
94
|
+
catalog: dict[str, Connector] | None,
|
|
95
|
+
) -> Report:
|
|
96
|
+
report = Report()
|
|
97
|
+
steps = walk_steps(workflow)
|
|
98
|
+
|
|
99
|
+
_step_ids_unique(steps, path, report)
|
|
100
|
+
_machines_present(steps, path, machines, report)
|
|
101
|
+
_declared_names(workflow, path, declared_connections, declared_secrets, report)
|
|
102
|
+
_connector_actions(steps, path, connector_pins, catalog, report)
|
|
103
|
+
_waits_are_decidable(steps, path, report)
|
|
104
|
+
_expressions_are_evaluable(workflow, path, report)
|
|
105
|
+
_schedule_can_satisfy_inputs(workflow, path, report)
|
|
106
|
+
|
|
107
|
+
return report
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _schedule_can_satisfy_inputs(workflow: dict[str, Any], path: str, report: Report) -> None:
|
|
111
|
+
"""A cron-triggered workflow can actually be started by its cron.
|
|
112
|
+
|
|
113
|
+
The scheduler starts a run with `{"scheduled_for": …}` and nothing else — a schedule
|
|
114
|
+
fires on a standing instruction, so there is no caller to supply anything. A required
|
|
115
|
+
input with no default is therefore unsatisfiable on every tick: the run fails at the
|
|
116
|
+
start (once the runner binds inputs) or, before that, walked the document over
|
|
117
|
+
`None`s. Either way the schedule is decoration.
|
|
118
|
+
|
|
119
|
+
Declaring a `default` is the fix, and it is per-bundle, which is per environment —
|
|
120
|
+
which is where a value like the mailbox to poll belongs.
|
|
121
|
+
"""
|
|
122
|
+
triggers = workflow.get("triggers") or []
|
|
123
|
+
if not any(isinstance(t, dict) and t.get("kind") == "schedule" for t in triggers):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
declared = workflow.get("inputs") or {}
|
|
127
|
+
if not isinstance(declared, dict):
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
unsatisfiable = sorted(
|
|
131
|
+
name
|
|
132
|
+
for name, spec in declared.items()
|
|
133
|
+
if isinstance(spec, dict) and spec.get("required") and "default" not in spec
|
|
134
|
+
)
|
|
135
|
+
if unsatisfiable:
|
|
136
|
+
report.fail(
|
|
137
|
+
"schedule-cannot-satisfy-inputs",
|
|
138
|
+
f"workflow has a `schedule` trigger but requires input(s) "
|
|
139
|
+
f"{', '.join(unsatisfiable)} with no default. A schedule supplies only "
|
|
140
|
+
"`scheduled_for`, so every tick would start a run that cannot proceed. "
|
|
141
|
+
"Give them defaults, or drop the schedule trigger and start the workflow "
|
|
142
|
+
"with its inputs",
|
|
143
|
+
path=path,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _expressions_are_evaluable(workflow: dict[str, Any], path: str, report: Report) -> None:
|
|
148
|
+
"""Every `{{ … }}` is one the runner can evaluate (`mkp_contracts.expressions`).
|
|
149
|
+
|
|
150
|
+
A bundle is validated before it can be pointed at (ADR 0008), and until this rule
|
|
151
|
+
existed that promise stopped at the document's shape: an expression the grammar does
|
|
152
|
+
not have was schema-valid, signed, deployed, and then either failed the first run
|
|
153
|
+
that reached the step or — worse, for a half-quoted comparison — compared false
|
|
154
|
+
forever without failing anything.
|
|
155
|
+
"""
|
|
156
|
+
for location, text in _template_strings(workflow, "").items():
|
|
157
|
+
for expression in expressions.expressions_in(text):
|
|
158
|
+
refusal = expressions.refusal(expression)
|
|
159
|
+
if refusal:
|
|
160
|
+
report.fail("unevaluable-expression", refusal, path=path, location=location)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _template_strings(node: Any, location: str) -> dict[str, str]:
|
|
164
|
+
"""Every string in the document that contains a `{{ … }}`, by location."""
|
|
165
|
+
found: dict[str, str] = {}
|
|
166
|
+
if isinstance(node, dict):
|
|
167
|
+
for key, value in node.items():
|
|
168
|
+
found.update(_template_strings(value, f"{location}.{key}" if location else str(key)))
|
|
169
|
+
elif isinstance(node, list):
|
|
170
|
+
for index, value in enumerate(node):
|
|
171
|
+
found.update(_template_strings(value, f"{location}[{index}]"))
|
|
172
|
+
elif isinstance(node, str) and expressions.TEMPLATE.search(node):
|
|
173
|
+
found[location] = node
|
|
174
|
+
return found
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _waits_are_decidable(
|
|
178
|
+
steps: list[tuple[dict[str, Any], str]], path: str, report: Report
|
|
179
|
+
) -> None:
|
|
180
|
+
"""A `wait` says either how long or what for, and never both or neither.
|
|
181
|
+
|
|
182
|
+
The schema declares `duration`, `until_event` and `deadline` as three independent
|
|
183
|
+
optional fields, which makes all four combinations schema-valid — including the two a
|
|
184
|
+
runner cannot act on. `duration` sleeps and `until_event` waits to be told; a step
|
|
185
|
+
carrying both is a document whose author meant one of them, and choosing for them would
|
|
186
|
+
be a runner deciding what a workflow does.
|
|
187
|
+
|
|
188
|
+
The interpreter refuses the same two shapes, because a bundle built before this rule
|
|
189
|
+
existed is still loadable. This is the half that means nobody ships one.
|
|
190
|
+
"""
|
|
191
|
+
for step, location in steps:
|
|
192
|
+
if step.get("kind") != "wait":
|
|
193
|
+
continue
|
|
194
|
+
duration, event = step.get("duration"), step.get("until_event")
|
|
195
|
+
if duration and event:
|
|
196
|
+
report.fail(
|
|
197
|
+
"wait-declares-both",
|
|
198
|
+
f"step {step.get('id')!r} is a wait with both `duration` and "
|
|
199
|
+
"`until_event`. One sleeps for a fixed time and the other holds until "
|
|
200
|
+
"something arrives — declare the one that was meant",
|
|
201
|
+
path=path,
|
|
202
|
+
location=location,
|
|
203
|
+
)
|
|
204
|
+
elif not duration and not event:
|
|
205
|
+
report.fail(
|
|
206
|
+
"wait-declares-nothing",
|
|
207
|
+
f"step {step.get('id')!r} is a wait with neither `duration` nor "
|
|
208
|
+
"`until_event`, so nothing says what it waits for or for how long. A run "
|
|
209
|
+
"reaching it would stop until its deadline and then fail",
|
|
210
|
+
path=path,
|
|
211
|
+
location=location,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _step_ids_unique(steps: list[tuple[dict[str, Any], str]], path: str, report: Report) -> None:
|
|
216
|
+
seen: dict[str, str] = {}
|
|
217
|
+
for step, location in steps:
|
|
218
|
+
step_id = step.get("id")
|
|
219
|
+
if not step_id:
|
|
220
|
+
continue
|
|
221
|
+
if step_id in seen:
|
|
222
|
+
report.fail(
|
|
223
|
+
"duplicate-step-id",
|
|
224
|
+
f"step id {step_id!r} is already used at {seen[step_id]}; "
|
|
225
|
+
"step ids are how results are referenced and how attempts are audited",
|
|
226
|
+
path=path,
|
|
227
|
+
location=location,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
seen[step_id] = location
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _machines_present(
|
|
234
|
+
steps: list[tuple[dict[str, Any], str]], path: str, machines: set[str], report: Report
|
|
235
|
+
) -> None:
|
|
236
|
+
for step, location in steps:
|
|
237
|
+
if step.get("kind") != "mklang":
|
|
238
|
+
continue
|
|
239
|
+
machine = step.get("machine")
|
|
240
|
+
if machine and machine not in machines:
|
|
241
|
+
report.fail(
|
|
242
|
+
"machine-missing",
|
|
243
|
+
f"machine {machine!r} is not in the bundle; a bundle pins everything that "
|
|
244
|
+
"affects behaviour (ADR 0008)",
|
|
245
|
+
path=path,
|
|
246
|
+
location=f"{location}.machine",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _declared_names(
|
|
251
|
+
workflow: dict[str, Any],
|
|
252
|
+
path: str,
|
|
253
|
+
declared_connections: set[str],
|
|
254
|
+
declared_secrets: set[str],
|
|
255
|
+
report: Report,
|
|
256
|
+
) -> None:
|
|
257
|
+
for name in sorted(connections(workflow) - declared_connections):
|
|
258
|
+
report.fail(
|
|
259
|
+
"undeclared-connection",
|
|
260
|
+
f"connection {name!r} is used but not declared in requires.connections; "
|
|
261
|
+
"a missing connection must fail the deploy, not the first run",
|
|
262
|
+
path=path,
|
|
263
|
+
)
|
|
264
|
+
for name in sorted(secret_refs(workflow) - declared_secrets):
|
|
265
|
+
report.fail(
|
|
266
|
+
"undeclared-secret",
|
|
267
|
+
f"secretRef {name!r} is used but not declared in requires.secrets",
|
|
268
|
+
path=path,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _connector_actions(
|
|
273
|
+
steps: list[tuple[dict[str, Any], str]],
|
|
274
|
+
path: str,
|
|
275
|
+
connector_pins: dict[str, int],
|
|
276
|
+
catalog: dict[str, Connector] | None,
|
|
277
|
+
report: Report,
|
|
278
|
+
) -> None:
|
|
279
|
+
for step, location in steps:
|
|
280
|
+
if step.get("kind") != "connector":
|
|
281
|
+
continue
|
|
282
|
+
connector_name = step.get("connector")
|
|
283
|
+
action_name = step.get("action")
|
|
284
|
+
if not connector_name or not action_name:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
if connector_name not in connector_pins:
|
|
288
|
+
report.fail(
|
|
289
|
+
"unknown-connector",
|
|
290
|
+
f"connector {connector_name!r} is not pinned in lock.connectors; "
|
|
291
|
+
"a newer manifest requires a new bundle",
|
|
292
|
+
path=path,
|
|
293
|
+
location=f"{location}.connector",
|
|
294
|
+
)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
if catalog is None:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
connector = catalog.get(connector_name)
|
|
301
|
+
if connector is None:
|
|
302
|
+
report.fail(
|
|
303
|
+
"unknown-connector",
|
|
304
|
+
f"connector {connector_name!r} is not in the catalog",
|
|
305
|
+
path=path,
|
|
306
|
+
location=f"{location}.connector",
|
|
307
|
+
)
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
if connector.version != connector_pins[connector_name]:
|
|
311
|
+
report.fail(
|
|
312
|
+
"connector-version-mismatch",
|
|
313
|
+
f"lock pins {connector_name} v{connector_pins[connector_name]} but the "
|
|
314
|
+
f"catalog holds v{connector.version}",
|
|
315
|
+
path=path,
|
|
316
|
+
location=f"{location}.connector",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
action = connector.action(action_name)
|
|
320
|
+
if action is None:
|
|
321
|
+
report.fail(
|
|
322
|
+
"unknown-action",
|
|
323
|
+
f"{connector_name} has no action {action_name!r}",
|
|
324
|
+
path=path,
|
|
325
|
+
location=f"{location}.action",
|
|
326
|
+
)
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
if action.get("effect") == "write" and not step.get("idempotency_key"):
|
|
330
|
+
report.fail(
|
|
331
|
+
"missing-idempotency-key",
|
|
332
|
+
f"{connector_name}.{action_name} is effect: write and this step has no "
|
|
333
|
+
"idempotency_key; durable execution is at-least-once, so without a key a "
|
|
334
|
+
"crash or a retry duplicates the effect (ADR 0009 Amendment 1)",
|
|
335
|
+
path=path,
|
|
336
|
+
location=location,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def check_machine_gates(text: str, path: str) -> Report:
|
|
341
|
+
"""Every `hook:` a machine declares names a gate the platform can resolve (`C21`).
|
|
342
|
+
|
|
343
|
+
mklang resolves a hook by name and answers `None` for one it does not know — and a gate
|
|
344
|
+
it cannot resolve **halts the run when the gate is reached**. Not at load, not at the
|
|
345
|
+
first state: when that gate is reached, which may be one model call later and only for
|
|
346
|
+
some inputs. So a bundle that names one is a bundle that builds, deploys, and then stops
|
|
347
|
+
somewhere expensive.
|
|
348
|
+
|
|
349
|
+
What a gate may name is `mkp_contracts.gates` — the platform's declarative vocabulary,
|
|
350
|
+
plus the two mklang builtins and the two parametric forms a machine may keep using. It
|
|
351
|
+
deliberately cannot name a Python predicate: ADR 0003 runs machines in the runner process
|
|
352
|
+
*because* "there is no customer code in a `.mkl`, only data", and a bundle carrying code
|
|
353
|
+
for its gates would take that reason away.
|
|
354
|
+
|
|
355
|
+
Read as YAML rather than through mklang, because this is the builder: `mklang check` has
|
|
356
|
+
already run over the same file and has no opinion about which hooks a host provides.
|
|
357
|
+
"""
|
|
358
|
+
report = Report()
|
|
359
|
+
try:
|
|
360
|
+
document = yaml.safe_load(text)
|
|
361
|
+
except yaml.YAMLError:
|
|
362
|
+
# `mklang check` owns "this file does not parse" and says it better. A second,
|
|
363
|
+
# vaguer copy of that failure would be two rules for one mistake.
|
|
364
|
+
return report
|
|
365
|
+
if not isinstance(document, dict):
|
|
366
|
+
return report
|
|
367
|
+
|
|
368
|
+
states = document.get("states")
|
|
369
|
+
if not isinstance(states, dict):
|
|
370
|
+
return report
|
|
371
|
+
|
|
372
|
+
for state_id, state in states.items():
|
|
373
|
+
if not isinstance(state, dict):
|
|
374
|
+
continue
|
|
375
|
+
for index, gate in enumerate(state.get("gates") or []):
|
|
376
|
+
if not isinstance(gate, dict):
|
|
377
|
+
continue
|
|
378
|
+
hook = gate.get("hook")
|
|
379
|
+
if not hook or gates.resolvable(hook):
|
|
380
|
+
continue
|
|
381
|
+
report.fail(
|
|
382
|
+
"machine-gate-unresolvable",
|
|
383
|
+
gates.reason(hook),
|
|
384
|
+
path=path,
|
|
385
|
+
location=f"states.{state_id}.gates[{index}].hook",
|
|
386
|
+
)
|
|
387
|
+
return report
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_spec_versions(specs: dict[str, str]) -> Report:
|
|
391
|
+
"""Every machine in a bundle must target one language spec version.
|
|
392
|
+
|
|
393
|
+
Two versions in one bundle means `lock.spec` cannot honestly say what the
|
|
394
|
+
machines were validated against.
|
|
395
|
+
"""
|
|
396
|
+
report = Report()
|
|
397
|
+
distinct = sorted(set(specs.values()))
|
|
398
|
+
if len(distinct) > 1:
|
|
399
|
+
detail = ", ".join(f"{path} -> {spec}" for path, spec in sorted(specs.items()))
|
|
400
|
+
report.fail(
|
|
401
|
+
"spec-mismatch",
|
|
402
|
+
f"machines declare more than one mklang spec version ({', '.join(distinct)}): {detail}",
|
|
403
|
+
)
|
|
404
|
+
return report
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def read_spec_version(text: str) -> str | None:
|
|
408
|
+
"""The `mklang:` key a `.mkl` declares — the language spec it targets."""
|
|
409
|
+
for line in text.splitlines():
|
|
410
|
+
stripped = line.strip()
|
|
411
|
+
if stripped.startswith("mklang:"):
|
|
412
|
+
return stripped.split(":", 1)[1].strip().strip("\"'")
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def machine_paths(paths: list[Path]) -> set[str]:
|
|
417
|
+
return {p.as_posix() for p in paths}
|
mkbundle/signing.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Signing a bundle with minisign. Verification lives in `mkp_contracts`.
|
|
2
|
+
|
|
3
|
+
A runner refuses an unsigned or unverifiable bundle (ADR 0008), so the signature is what
|
|
4
|
+
makes a pulled artifact trustworthy in a plane we may not operate. The algorithm is
|
|
5
|
+
minisign at launch and cosign when an audit needs provenance, which is why
|
|
6
|
+
`signature.algorithm` exists in the schema rather than being assumed.
|
|
7
|
+
|
|
8
|
+
**Signing is a build-time act; verifying is a runtime one.** This module signs, and
|
|
9
|
+
re-exports the verifier so the builder can check its own output — but the verifier
|
|
10
|
+
itself is in `mkp_contracts.signature`, because the runner has to do the same check at
|
|
11
|
+
load and must not depend on a build tool to do it.
|
|
12
|
+
|
|
13
|
+
What is signed is exactly the canonical manifest bytes — the same bytes the digest is
|
|
14
|
+
taken over, so there is one canonical form and not two. `signature` is outside that
|
|
15
|
+
form, which is what lets a signature be inserted into a manifest without changing the
|
|
16
|
+
bundle's identity.
|
|
17
|
+
|
|
18
|
+
`signature.value` holds the whole `.minisig` file, base64-encoded: it round-trips to
|
|
19
|
+
bytes minisign verifies as-is, and it stays a single JSON-friendly line.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from mkp_contracts.signature import ALGORITHM, CANONICAL_NAME, available, key_id, verify
|
|
30
|
+
|
|
31
|
+
from .errors import BuildError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require() -> None:
|
|
35
|
+
if not available():
|
|
36
|
+
raise BuildError("minisign is not on PATH; a bundle cannot be signed or verified")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sign(canonical: bytes, secret_key: Path, public_key: Path, *, digest: str) -> dict[str, str]:
|
|
40
|
+
"""Sign the canonical manifest, returning the manifest's `signature` object.
|
|
41
|
+
|
|
42
|
+
The trusted comment carries the bundle digest rather than minisign's default
|
|
43
|
+
(a timestamp and the temporary file's name), so what the signature asserts is
|
|
44
|
+
legible without unpacking anything.
|
|
45
|
+
"""
|
|
46
|
+
_require()
|
|
47
|
+
with tempfile.TemporaryDirectory() as workspace:
|
|
48
|
+
target = Path(workspace) / CANONICAL_NAME
|
|
49
|
+
target.write_bytes(canonical)
|
|
50
|
+
signature_path = target.with_suffix(".minisig")
|
|
51
|
+
|
|
52
|
+
# A fixed argv list and no shell. The key path is the operator's own argument
|
|
53
|
+
# and reaches the process as one list element, never as text to be parsed.
|
|
54
|
+
completed = subprocess.run(
|
|
55
|
+
[
|
|
56
|
+
ALGORITHM,
|
|
57
|
+
"-S",
|
|
58
|
+
"-s",
|
|
59
|
+
str(secret_key),
|
|
60
|
+
"-m",
|
|
61
|
+
str(target),
|
|
62
|
+
"-x",
|
|
63
|
+
str(signature_path),
|
|
64
|
+
"-t",
|
|
65
|
+
f"bundle:{digest}",
|
|
66
|
+
"-c",
|
|
67
|
+
"mklang-platform bundle",
|
|
68
|
+
],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
)
|
|
72
|
+
if completed.returncode != 0:
|
|
73
|
+
raise BuildError(
|
|
74
|
+
f"minisign refused to sign (exit {completed.returncode}): "
|
|
75
|
+
f"{completed.stderr.strip() or completed.stdout.strip()}. "
|
|
76
|
+
"A build key must be unencrypted — minisign prompts on a terminal otherwise"
|
|
77
|
+
)
|
|
78
|
+
payload = signature_path.read_bytes()
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"algorithm": ALGORITHM,
|
|
82
|
+
"key_id": key_id(public_key),
|
|
83
|
+
"value": base64.b64encode(payload).decode("ascii"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["ALGORITHM", "available", "key_id", "sign", "verify"]
|
mkbundle/source.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Reading a bundle source directory.
|
|
2
|
+
|
|
3
|
+
src/
|
|
4
|
+
bundle.yaml project, pinned connector versions, requires
|
|
5
|
+
workflows/*.yaml one workflow definition each
|
|
6
|
+
machines/*.mkl the machines those workflows commission
|
|
7
|
+
assets/** templates, prompts, fixtures
|
|
8
|
+
|
|
9
|
+
`bundle.yaml` declares intent. Everything a build observes — the file inventory,
|
|
10
|
+
their digests, the mklang and spec versions actually used, the build time — is an
|
|
11
|
+
output and may not be declared, because a declared value can disagree with what
|
|
12
|
+
happened.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from .errors import BuildError
|
|
24
|
+
|
|
25
|
+
SOURCE_MANIFEST = "bundle.yaml"
|
|
26
|
+
|
|
27
|
+
_DECLARABLE = {"schema_version", "project", "source", "lock", "requires"}
|
|
28
|
+
_PRODUCED = {"created", "contents", "signature"}
|
|
29
|
+
_PRODUCED_LOCK = {"mklang", "spec"}
|
|
30
|
+
|
|
31
|
+
_WORKFLOW_SUFFIXES = (".yaml", ".yml", ".json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BundleSource:
|
|
36
|
+
root: Path
|
|
37
|
+
project: str
|
|
38
|
+
declared: dict[str, Any]
|
|
39
|
+
workflows: list[Path] = field(default_factory=list)
|
|
40
|
+
machines: list[Path] = field(default_factory=list)
|
|
41
|
+
assets: list[Path] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def connector_pins(self) -> dict[str, int]:
|
|
45
|
+
return dict(self.declared.get("lock", {}).get("connectors", {}))
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def requires(self) -> dict[str, Any]:
|
|
49
|
+
return dict(self.declared.get("requires", {}))
|
|
50
|
+
|
|
51
|
+
def read(self, relative: Path) -> bytes:
|
|
52
|
+
return (self.root / relative).read_bytes()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(root: Path) -> BundleSource:
|
|
56
|
+
root = root.resolve()
|
|
57
|
+
manifest_path = root / SOURCE_MANIFEST
|
|
58
|
+
if not manifest_path.is_file():
|
|
59
|
+
raise BuildError(f"{root}: no {SOURCE_MANIFEST} — this is not a bundle source directory")
|
|
60
|
+
|
|
61
|
+
declared = yaml.safe_load(manifest_path.read_text()) or {}
|
|
62
|
+
if not isinstance(declared, dict):
|
|
63
|
+
raise BuildError(f"{manifest_path}: expected a mapping")
|
|
64
|
+
|
|
65
|
+
_reject_produced_fields(declared, manifest_path)
|
|
66
|
+
|
|
67
|
+
project = declared.get("project")
|
|
68
|
+
if not project:
|
|
69
|
+
raise BuildError(f"{manifest_path}: `project` is required")
|
|
70
|
+
|
|
71
|
+
return BundleSource(
|
|
72
|
+
root=root,
|
|
73
|
+
project=project,
|
|
74
|
+
declared=declared,
|
|
75
|
+
workflows=_collect(root, "workflows", _WORKFLOW_SUFFIXES),
|
|
76
|
+
machines=_collect(root, "machines", (".mkl",)),
|
|
77
|
+
assets=_collect(root, "assets", None),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _reject_produced_fields(declared: dict[str, Any], path: Path) -> None:
|
|
82
|
+
"""Refuse a source manifest that declares what the build observes."""
|
|
83
|
+
for key in sorted(_PRODUCED & declared.keys()):
|
|
84
|
+
raise BuildError(
|
|
85
|
+
f"{path}: `{key}` is produced by the build and must not be declared — "
|
|
86
|
+
"a declared value can disagree with what was actually built"
|
|
87
|
+
)
|
|
88
|
+
for key in sorted(_PRODUCED_LOCK & set(declared.get("lock", {}))):
|
|
89
|
+
raise BuildError(
|
|
90
|
+
f"{path}: `lock.{key}` is read off the toolchain that validates the bundle "
|
|
91
|
+
"and must not be declared"
|
|
92
|
+
)
|
|
93
|
+
unknown = sorted(declared.keys() - _DECLARABLE)
|
|
94
|
+
if unknown:
|
|
95
|
+
raise BuildError(f"{path}: unknown field(s) {', '.join(unknown)}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _collect(root: Path, subdir: str, suffixes: tuple[str, ...] | None) -> list[Path]:
|
|
99
|
+
directory = root / subdir
|
|
100
|
+
if not directory.is_dir():
|
|
101
|
+
return []
|
|
102
|
+
found = [
|
|
103
|
+
p
|
|
104
|
+
for p in sorted(directory.rglob("*"))
|
|
105
|
+
if p.is_file() and (suffixes is None or p.suffix in suffixes)
|
|
106
|
+
]
|
|
107
|
+
return [p.relative_to(root) for p in found]
|
mkbundle/toolchain.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The mklang toolchain, called as a subprocess.
|
|
2
|
+
|
|
3
|
+
mklang is a pinned dependency and stays a language (ADR 0010): the platform runs
|
|
4
|
+
its CLI, it does not reimplement `check`. The versions recorded in a bundle's
|
|
5
|
+
lock are the ones that actually validated it, read off the toolchain rather than
|
|
6
|
+
declared by the author — a declared version can be wrong, an observed one cannot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .errors import BuildError, Report
|
|
18
|
+
|
|
19
|
+
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+\S*)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def available() -> bool:
|
|
23
|
+
return shutil.which("mklang") is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def version() -> str:
|
|
27
|
+
"""The mklang package version, for `lock.mklang`."""
|
|
28
|
+
if not available():
|
|
29
|
+
raise BuildError("mklang is not on PATH; the bundle's machines cannot be validated")
|
|
30
|
+
# `mklang` by name, from PATH, on purpose (S607): the version it reports is what
|
|
31
|
+
# goes into the bundle's lock, so the toolchain that validated a bundle is the one the
|
|
32
|
+
# builder had installed. A hard-coded path would be wrong on the next machine.
|
|
33
|
+
out = subprocess.run(
|
|
34
|
+
["mklang", "--version"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
check=True,
|
|
38
|
+
).stdout.strip()
|
|
39
|
+
match = _VERSION_RE.search(out)
|
|
40
|
+
if not match:
|
|
41
|
+
raise BuildError(f"cannot read a version out of `mklang --version` output: {out!r}")
|
|
42
|
+
return match.group(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(machines: list[Path], root: Path) -> Report:
|
|
46
|
+
"""Run `mklang check` over every machine in the bundle.
|
|
47
|
+
|
|
48
|
+
A bundle whose machines do not check does not become a deployment candidate
|
|
49
|
+
(ADR 0008), so this is a build-time gate and not a warning.
|
|
50
|
+
"""
|
|
51
|
+
report = Report()
|
|
52
|
+
if not machines:
|
|
53
|
+
return report
|
|
54
|
+
if not available():
|
|
55
|
+
raise BuildError("mklang is not on PATH; the bundle's machines cannot be validated")
|
|
56
|
+
|
|
57
|
+
# Same reasoning as `version()`: by name from PATH, fixed argv, no shell. The
|
|
58
|
+
# machine paths are inside the bundle root and arrive as separate list elements.
|
|
59
|
+
completed = subprocess.run(
|
|
60
|
+
["mklang", "check", "--format", "json", *[str(root / m) for m in machines]],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
result = json.loads(completed.stdout)
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
raise BuildError(
|
|
68
|
+
f"`mklang check` produced output that is not JSON (exit {completed.returncode}): "
|
|
69
|
+
f"{completed.stdout[:400]}{completed.stderr[:400]}"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
for item in result.get("items", []):
|
|
73
|
+
relative = Path(item["path"]).resolve().relative_to(root.resolve()).as_posix()
|
|
74
|
+
for error in item.get("errors", []):
|
|
75
|
+
report.fail("machine-check", _describe(error), path=relative)
|
|
76
|
+
if item.get("status") not in ("ok", "warning") and not item.get("errors"):
|
|
77
|
+
report.fail("machine-check", f"status {item.get('status')!r}", path=relative)
|
|
78
|
+
return report
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _describe(error: object) -> str:
|
|
82
|
+
if isinstance(error, dict):
|
|
83
|
+
code = error.get("code") or error.get("kind") or "error"
|
|
84
|
+
message = error.get("message") or error.get("detail") or ""
|
|
85
|
+
return f"{code}: {message}".strip(": ")
|
|
86
|
+
return str(error)
|