mkbundle 0.2.0__tar.gz → 1.1.21__tar.gz
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-0.2.0 → mkbundle-1.1.21}/.gitignore +8 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/PKG-INFO +2 -2
- {mkbundle-0.2.0 → mkbundle-1.1.21}/README.md +30 -21
- {mkbundle-0.2.0 → mkbundle-1.1.21}/pyproject.toml +1 -1
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/__init__.py +1 -1
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/build.py +12 -7
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/cli.py +36 -4
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/rules.py +32 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/conftest.py +18 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/connectors/microsoft-graph/connector.yaml +1 -1
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_build.py +23 -0
- mkbundle-1.1.21/tests/test_machine_capabilities.py +15 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_rules.py +44 -0
- mkbundle-1.1.21/tests/test_validate.py +126 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_version.py +12 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/uv.lock +2 -2
- mkbundle-0.2.0/tests/test_validate.py +0 -56
- {mkbundle-0.2.0 → mkbundle-1.1.21}/LICENSE +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/catalog.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/errors.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/py.typed +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/signing.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/source.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/src/mkbundle/toolchain.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/__init__.py +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/connectors/slack/connector.yaml +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/bundle.yaml +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/machines/triage.mkl +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/fixtures/valid/workflows/triage.yaml +0 -0
- {mkbundle-0.2.0 → mkbundle-1.1.21}/tests/test_signing.py +0 -0
|
@@ -10,6 +10,7 @@ build/
|
|
|
10
10
|
.ruff_cache/
|
|
11
11
|
.pytest_cache/
|
|
12
12
|
.coverage
|
|
13
|
+
web/app/coverage/
|
|
13
14
|
htmlcov/
|
|
14
15
|
|
|
15
16
|
# Node (web/)
|
|
@@ -34,6 +35,10 @@ deploy/backups/
|
|
|
34
35
|
# Runtime state
|
|
35
36
|
.mklang-platform/
|
|
36
37
|
*.db
|
|
38
|
+
# SQLite in WAL mode, which is what the BFF's session store asks for: running the
|
|
39
|
+
# service or its tests leaves these two beside the ignored database.
|
|
40
|
+
*.db-wal
|
|
41
|
+
*.db-shm
|
|
37
42
|
*.sqlite3
|
|
38
43
|
run-artifacts/
|
|
39
44
|
|
|
@@ -41,3 +46,6 @@ run-artifacts/
|
|
|
41
46
|
.DS_Store
|
|
42
47
|
.idea/
|
|
43
48
|
.vscode/
|
|
49
|
+
|
|
50
|
+
# The site pin is data the host pulls — tracked on purpose (deploy-site.yml writes it).
|
|
51
|
+
!deploy/pins/caddy.env
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# mkbundle — bundle build and validation
|
|
2
2
|
|
|
3
3
|
The gate between authoring and everything downstream. A bundle that fails here never
|
|
4
|
-
becomes a deployment candidate ([ADR 0008](../../docs/adr/0008-bundles-and-pull-based-deployment.md))
|
|
4
|
+
becomes a deployment candidate ([ADR 0008](../../docs/adr/0008-bundles-and-pull-based-deployment.md));
|
|
5
|
+
one that passes is what [`mkctl bundle push`](../mkctl/README.md) registers and uploads.
|
|
5
6
|
|
|
6
7
|
```
|
|
7
8
|
mkbundle validate <path>... a workflow, connector manifest, or bundle manifest
|
|
8
|
-
|
|
9
|
+
(--kind to skip detection and name the schema)
|
|
10
|
+
mkbundle validate-connectors <dir> validate every connector manifest in a deployment
|
|
11
|
+
manifest set before a bundle is signed
|
|
12
|
+
mkbundle build <src> -o <out> validate a source tree and archive it; `-o` is
|
|
13
|
+
`--output`, and omitting it validates without writing.
|
|
14
|
+
A target without a .tar.gz suffix is a directory
|
|
15
|
+
(created if missing); the archive is named in it
|
|
9
16
|
mkbundle inspect <archive> the manifest and digest of a built bundle
|
|
10
17
|
mkbundle verify <archive> its signature, and its files against the manifest
|
|
11
18
|
```
|
|
@@ -30,8 +37,9 @@ what actually validated the bundle; an observed one cannot.
|
|
|
30
37
|
|
|
31
38
|
## Identity
|
|
32
39
|
|
|
33
|
-
A bundle's identity is the sha256 of its canonicalized manifest
|
|
34
|
-
|
|
40
|
+
A bundle's identity is the sha256 of its canonicalized manifest — computed by
|
|
41
|
+
[`mkp-contracts`](../../packages/mkp-contracts/README.md), which is also what the control
|
|
42
|
+
plane recomputes it with. Three fields are outside that canonical form:
|
|
35
43
|
|
|
36
44
|
| Field | Why |
|
|
37
45
|
| ----------- | --------------------------------------------------------------------------------------- |
|
|
@@ -52,23 +60,24 @@ no filename in the gzip header — so identical content also produces identical
|
|
|
52
60
|
Schema validation is the shape. These are the rules, and they are build-time because
|
|
53
61
|
each is answerable from the bundle alone (`schema/README.md`):
|
|
54
62
|
|
|
55
|
-
| Rule
|
|
56
|
-
|
|
|
57
|
-
| `missing-idempotency-key`
|
|
58
|
-
| `machine-missing`
|
|
59
|
-
| `machine-check`
|
|
60
|
-
| `spec-mismatch`
|
|
61
|
-
| `undeclared-connection`
|
|
62
|
-
| `undeclared-secret`
|
|
63
|
-
| `unknown-connector`
|
|
64
|
-
| `connector-version-mismatch`
|
|
65
|
-
| `unknown-action`
|
|
66
|
-
| `duplicate-step-id`
|
|
67
|
-
| `wait-declares-both`
|
|
68
|
-
| `wait-declares-nothing`
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
63
|
+
| Rule | What it catches |
|
|
64
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `missing-idempotency-key` | An `effect: write` action with no key — see below |
|
|
66
|
+
| `machine-missing` | An mklang step pointing outside the bundle |
|
|
67
|
+
| `machine-check` | A machine `mklang check` rejects |
|
|
68
|
+
| `spec-mismatch` | Machines targeting different language spec versions |
|
|
69
|
+
| `undeclared-connection` | A connection used but not in `requires` |
|
|
70
|
+
| `undeclared-secret` | A secretRef used but not in `requires` |
|
|
71
|
+
| `unknown-connector` | A connector not pinned in `lock.connectors` |
|
|
72
|
+
| `connector-version-mismatch` | A pin that disagrees with the catalog |
|
|
73
|
+
| `unknown-action` | An action the connector manifest does not have |
|
|
74
|
+
| `duplicate-step-id` | Two steps sharing an id within a workflow |
|
|
75
|
+
| `wait-declares-both` | A `wait` with both `duration` and `until_event` |
|
|
76
|
+
| `wait-declares-nothing` | A `wait` with neither, so nothing says what it waits for |
|
|
77
|
+
| `connector-timeout-out-of-bounds` | A connector step `timeout` above 300 s, zero, or not a duration ([ADR 0033](../../docs/adr/0033-a-connector-step-timeout-bounds-its-call.md)) |
|
|
78
|
+
| `machine-gate-unresolvable` | A machine gating on a hook the platform cannot answer |
|
|
79
|
+
| `duplicate-workflow-name` | Two workflows sharing a name within the bundle |
|
|
80
|
+
| `schema` | A document that does not match its contract |
|
|
72
81
|
|
|
73
82
|
`missing-idempotency-key` is the one that matters most. Durable execution is
|
|
74
83
|
at-least-once for external effects ([ADR 0009](../../docs/adr/0009-durable-execution-engine.md)
|
|
@@ -186,12 +186,10 @@ def _assemble(
|
|
|
186
186
|
spec_versions = sorted(set(specs.values()))
|
|
187
187
|
if not spec_versions:
|
|
188
188
|
declared_spec = bundle.declared.get("lock", {}).get("spec")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
spec_versions = [str(declared_spec)]
|
|
189
|
+
# An explicit no-workload bundle has no machine from which to observe a spec.
|
|
190
|
+
# Keep its identity deterministic without making ordinary source manifests
|
|
191
|
+
# declare a produced lock field.
|
|
192
|
+
spec_versions = [str(declared_spec or "0.3")]
|
|
195
193
|
|
|
196
194
|
contents: dict[str, Any] = {
|
|
197
195
|
"workflows": [_file_entry(bundle, p) for p in bundle.workflows],
|
|
@@ -232,7 +230,14 @@ def _write_archive(
|
|
|
232
230
|
archive is what makes "nothing is rebuilt on promote" checkable rather than
|
|
233
231
|
merely asserted.
|
|
234
232
|
"""
|
|
235
|
-
target
|
|
233
|
+
# A target without a suffix is a directory to fill, not a file to write:
|
|
234
|
+
# `-o out/` loses its trailing slash inside Path, so on a fresh checkout
|
|
235
|
+
# `is_dir()` alone wrote a single extensionless file named `out`.
|
|
236
|
+
if output.is_dir() or not output.suffix:
|
|
237
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
target = output / f"{bundle.project}-{digest[:12]}.tar.gz"
|
|
239
|
+
else:
|
|
240
|
+
target = output
|
|
236
241
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
237
242
|
|
|
238
243
|
full_manifest = json.dumps(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The `
|
|
1
|
+
"""The `mkbundle` command line.
|
|
2
2
|
|
|
3
3
|
Three verbs: validate a document, build a bundle, inspect a built one. One tool
|
|
4
4
|
rather than a builder and a separate validator, because the rules would otherwise
|
|
@@ -17,6 +17,7 @@ from mkp_contracts import schemas
|
|
|
17
17
|
from mkp_contracts.canonical import manifest_digest
|
|
18
18
|
|
|
19
19
|
from . import build as build_module
|
|
20
|
+
from .catalog import load as load_catalog
|
|
20
21
|
from .errors import BuildError, Report
|
|
21
22
|
|
|
22
23
|
|
|
@@ -34,15 +35,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
34
35
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
35
36
|
|
|
36
37
|
validate = subparsers.add_parser(
|
|
37
|
-
"validate", help="validate a workflow, connector
|
|
38
|
+
"validate", help="validate a workflow, connector, bundle, or report manifest"
|
|
38
39
|
)
|
|
39
40
|
validate.add_argument("paths", nargs="+", type=Path)
|
|
40
41
|
validate.add_argument(
|
|
41
42
|
"--kind",
|
|
42
|
-
choices=schemas.
|
|
43
|
+
choices=schemas.DOCUMENT_KINDS,
|
|
43
44
|
help="skip detection and validate against this schema",
|
|
44
45
|
)
|
|
45
46
|
|
|
47
|
+
subparsers.add_parser(
|
|
48
|
+
"validate-connectors", help="validate every connector manifest in a manifest set"
|
|
49
|
+
).add_argument("directory", type=Path)
|
|
50
|
+
|
|
46
51
|
build = subparsers.add_parser("build", help="validate a bundle source tree and archive it")
|
|
47
52
|
build.add_argument("src", type=Path)
|
|
48
53
|
build.add_argument(
|
|
@@ -72,6 +77,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
72
77
|
try:
|
|
73
78
|
if args.command == "validate":
|
|
74
79
|
return _validate(args)
|
|
80
|
+
if args.command == "validate-connectors":
|
|
81
|
+
return _validate_connectors(args)
|
|
75
82
|
if args.command == "build":
|
|
76
83
|
return _build(args)
|
|
77
84
|
if args.command == "verify":
|
|
@@ -101,6 +108,31 @@ def _validate(args: argparse.Namespace) -> int:
|
|
|
101
108
|
return _report(report, args.format, ok_message=f"{len(args.paths)} document(s) valid")
|
|
102
109
|
|
|
103
110
|
|
|
111
|
+
def _validate_connectors(args: argparse.Namespace) -> int:
|
|
112
|
+
report = Report()
|
|
113
|
+
try:
|
|
114
|
+
manifests = load_catalog(args.directory)
|
|
115
|
+
except BuildError as exc:
|
|
116
|
+
report.fail("connector-manifests", str(exc), path=str(args.directory))
|
|
117
|
+
return _report(report, args.format, ok_message="connector manifests valid")
|
|
118
|
+
if not manifests:
|
|
119
|
+
report.fail(
|
|
120
|
+
"empty-connector-manifests",
|
|
121
|
+
"no connector manifests found",
|
|
122
|
+
path=str(args.directory),
|
|
123
|
+
)
|
|
124
|
+
for name, connector in manifests.items():
|
|
125
|
+
document = schemas.load_document(connector.path)
|
|
126
|
+
report.extend(schemas.validate_document(document, "connector", path=str(connector.path)))
|
|
127
|
+
if connector.path.parent.name != name:
|
|
128
|
+
report.fail(
|
|
129
|
+
"catalog-name-mismatch",
|
|
130
|
+
f"manifest name {name!r} does not match its directory",
|
|
131
|
+
path=str(connector.path),
|
|
132
|
+
)
|
|
133
|
+
return _report(report, args.format, ok_message=f"{len(manifests)} connector manifest(s) valid")
|
|
134
|
+
|
|
135
|
+
|
|
104
136
|
def _build(args: argparse.Namespace) -> int:
|
|
105
137
|
connectors = args.connectors
|
|
106
138
|
if connectors is None:
|
|
@@ -195,7 +227,7 @@ def _emit_error(message: str, output_format: str) -> None:
|
|
|
195
227
|
if output_format == "json":
|
|
196
228
|
print(json.dumps({"ok": False, "error": message}, indent=2))
|
|
197
229
|
else:
|
|
198
|
-
print(f"
|
|
230
|
+
print(f"mkbundle: {message}", file=sys.stderr)
|
|
199
231
|
|
|
200
232
|
|
|
201
233
|
if __name__ == "__main__":
|
|
@@ -18,7 +18,9 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
20
|
import yaml
|
|
21
|
+
from mkp_contracts import connectors as connector_contracts
|
|
21
22
|
from mkp_contracts import expressions, gates
|
|
23
|
+
from mkp_contracts.capabilities import machine_tool_refusal
|
|
22
24
|
|
|
23
25
|
from .catalog import Connector
|
|
24
26
|
from .errors import Report
|
|
@@ -101,6 +103,7 @@ def check_workflow(
|
|
|
101
103
|
_declared_names(workflow, path, declared_connections, declared_secrets, report)
|
|
102
104
|
_connector_actions(steps, path, connector_pins, catalog, report)
|
|
103
105
|
_waits_are_decidable(steps, path, report)
|
|
106
|
+
_connector_timeouts_within_cap(steps, path, report)
|
|
104
107
|
_expressions_are_evaluable(workflow, path, report)
|
|
105
108
|
_schedule_can_satisfy_inputs(workflow, path, report)
|
|
106
109
|
|
|
@@ -212,6 +215,28 @@ def _waits_are_decidable(
|
|
|
212
215
|
)
|
|
213
216
|
|
|
214
217
|
|
|
218
|
+
def _connector_timeouts_within_cap(
|
|
219
|
+
steps: list[tuple[dict[str, Any], str]], path: str, report: Report
|
|
220
|
+
) -> None:
|
|
221
|
+
"""A connector step's `timeout` bounds its HTTP call, up to the platform cap (ADR 0033).
|
|
222
|
+
|
|
223
|
+
Refused here rather than clamped at run time: a bundle asking for more than the cap is
|
|
224
|
+
a bundle whose author expects a call the runner will not wait for. The runner refuses
|
|
225
|
+
the same step terminally, because a bundle signed before this rule is still loadable.
|
|
226
|
+
"""
|
|
227
|
+
for step, location in steps:
|
|
228
|
+
if step.get("kind") != "connector" or "timeout" not in step:
|
|
229
|
+
continue
|
|
230
|
+
refusal = connector_contracts.connector_timeout_refusal(step["timeout"])
|
|
231
|
+
if refusal:
|
|
232
|
+
report.fail(
|
|
233
|
+
"connector-timeout-out-of-bounds",
|
|
234
|
+
f"step {step.get('id')!r}: {refusal}",
|
|
235
|
+
path=path,
|
|
236
|
+
location=f"{location}.timeout",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
215
240
|
def _step_ids_unique(steps: list[tuple[dict[str, Any], str]], path: str, report: Report) -> None:
|
|
216
241
|
seen: dict[str, str] = {}
|
|
217
242
|
for step, location in steps:
|
|
@@ -372,6 +397,13 @@ def check_machine_gates(text: str, path: str) -> Report:
|
|
|
372
397
|
for state_id, state in states.items():
|
|
373
398
|
if not isinstance(state, dict):
|
|
374
399
|
continue
|
|
400
|
+
if state.get("tool") is not None:
|
|
401
|
+
report.fail(
|
|
402
|
+
"machine-tool-forbidden",
|
|
403
|
+
machine_tool_refusal(str(state["tool"])),
|
|
404
|
+
path=path,
|
|
405
|
+
location=f"states.{state_id}.tool",
|
|
406
|
+
)
|
|
375
407
|
for index, gate in enumerate(state.get("gates") or []):
|
|
376
408
|
if not isinstance(gate, dict):
|
|
377
409
|
continue
|
|
@@ -29,6 +29,24 @@ def valid_bundle() -> Path:
|
|
|
29
29
|
return VALID
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def empty_bundle(tmp_path: Path) -> Path:
|
|
34
|
+
root = tmp_path / "empty"
|
|
35
|
+
(root / "workflows").mkdir(parents=True)
|
|
36
|
+
(root / "machines").mkdir()
|
|
37
|
+
(root / "assets").mkdir()
|
|
38
|
+
(root / "bundle.yaml").write_text(
|
|
39
|
+
"schema_version: 1\n"
|
|
40
|
+
"project: empty\n"
|
|
41
|
+
"lock:\n"
|
|
42
|
+
" connectors: {}\n"
|
|
43
|
+
"requires:\n"
|
|
44
|
+
" connections: []\n",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
return root
|
|
48
|
+
|
|
49
|
+
|
|
32
50
|
@pytest.fixture
|
|
33
51
|
def variant(tmp_path: Path) -> Callable[[Callable[[Path], None]], Path]:
|
|
34
52
|
"""A copy of the valid bundle with one thing changed."""
|
|
@@ -12,7 +12,7 @@ egress:
|
|
|
12
12
|
auth:
|
|
13
13
|
kind: oauth2_authcode
|
|
14
14
|
authorize_url: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
|
|
15
|
-
token_url: https://login.microsoftonline.com/
|
|
15
|
+
token_url: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
|
|
16
16
|
scopes:
|
|
17
17
|
- Mail.ReadWrite
|
|
18
18
|
- Mail.Send
|
|
@@ -19,6 +19,16 @@ def test_valid_bundle_builds(valid_bundle: Path, connectors: Path) -> None:
|
|
|
19
19
|
assert len(result.digest) == 64
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def test_explicit_empty_bundle_builds(empty_bundle: Path, connectors: Path) -> None:
|
|
23
|
+
"""An organisation with no enabled plugins still needs a signed deployable pointer."""
|
|
24
|
+
result = build_module.build(empty_bundle, connectors_dir=connectors)
|
|
25
|
+
|
|
26
|
+
assert result.report.ok, [str(f) for f in result.report.failures]
|
|
27
|
+
assert result.manifest["contents"]["workflows"] == []
|
|
28
|
+
assert result.manifest["contents"]["machines"] == []
|
|
29
|
+
assert result.manifest["lock"]["spec"] == "0.3"
|
|
30
|
+
|
|
31
|
+
|
|
22
32
|
def test_manifest_records_what_the_build_observed(valid_bundle: Path, connectors: Path) -> None:
|
|
23
33
|
manifest = build_module.build(valid_bundle, connectors_dir=connectors).manifest
|
|
24
34
|
|
|
@@ -93,3 +103,16 @@ def test_without_a_catalog_the_skipped_rules_are_named(valid_bundle: Path) -> No
|
|
|
93
103
|
|
|
94
104
|
assert result.report.ok
|
|
95
105
|
assert any("missing-idempotency-key" in rule for rule in result.report.skipped_rules)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_a_directory_output_that_does_not_exist_yet_is_still_a_directory(
|
|
109
|
+
valid_bundle: Path, connectors: Path, tmp_path: Path
|
|
110
|
+
) -> None:
|
|
111
|
+
"""`-o out/` on a fresh checkout used to write a single extensionless file
|
|
112
|
+
named `out` — Path drops the trailing slash, so `is_dir()` was the only
|
|
113
|
+
signal and a not-yet-created directory failed it."""
|
|
114
|
+
out = tmp_path / "out"
|
|
115
|
+
result = build_module.build(valid_bundle, connectors_dir=connectors, output=out)
|
|
116
|
+
assert result.archive is not None
|
|
117
|
+
assert result.archive.parent == out
|
|
118
|
+
assert result.archive.name.endswith(".tar.gz")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Build-time capability admission uses the runner's closed policy."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from mkbundle.rules import check_machine_gates
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize("tool", ["read_file", "write_file", "calc", "search", "plugin"])
|
|
9
|
+
def test_machine_tool_is_refused_at_build_time(tool):
|
|
10
|
+
report = check_machine_gates(f"states:\n read:\n tool: {tool}\n", "machine.mkl")
|
|
11
|
+
assert not report.ok
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_judgement_machine_remains_valid():
|
|
15
|
+
assert check_machine_gates("states:\n decide:\n execution: classify\n", "m.mkl").ok
|
|
@@ -79,6 +79,22 @@ def test_undeclared_connection_fails(
|
|
|
79
79
|
assert "undeclared-connection" in rules_broken(variant(undeclare), connectors)
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def test_an_mklang_connection_must_be_declared(
|
|
83
|
+
variant: Callable[[Callable[[Path], None]], Path], connectors: Path
|
|
84
|
+
) -> None:
|
|
85
|
+
def add_mklang_connection(root: Path) -> None:
|
|
86
|
+
def mutate(document: dict) -> None:
|
|
87
|
+
for step in document["steps"]:
|
|
88
|
+
if step.get("kind") == "mklang":
|
|
89
|
+
step["connection"] = "llm"
|
|
90
|
+
return
|
|
91
|
+
raise AssertionError("no mklang step")
|
|
92
|
+
|
|
93
|
+
edit_yaml(root / "workflows" / "triage.yaml", mutate)
|
|
94
|
+
|
|
95
|
+
assert "undeclared-connection" in rules_broken(variant(add_mklang_connection), connectors)
|
|
96
|
+
|
|
97
|
+
|
|
82
98
|
def test_undeclared_secret_fails(
|
|
83
99
|
variant: Callable[[Callable[[Path], None]], Path], connectors: Path
|
|
84
100
|
) -> None:
|
|
@@ -184,6 +200,34 @@ def test_a_wait_with_one_of_the_two_is_accepted(
|
|
|
184
200
|
assert not {"wait-declares-both", "wait-declares-nothing"} & broken
|
|
185
201
|
|
|
186
202
|
|
|
203
|
+
def _connector_timeout(duration: str) -> Callable[[Path], None]:
|
|
204
|
+
def declare(root: Path) -> None:
|
|
205
|
+
def mutate(document: dict) -> None:
|
|
206
|
+
step = document["steps"][0]
|
|
207
|
+
assert step["kind"] == "connector"
|
|
208
|
+
step["timeout"] = duration
|
|
209
|
+
|
|
210
|
+
edit_yaml(root / "workflows" / "triage.yaml", mutate)
|
|
211
|
+
|
|
212
|
+
return declare
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_a_connector_timeout_above_the_cap_fails(
|
|
216
|
+
variant: Callable[[Callable[[Path], None]], Path], connectors: Path
|
|
217
|
+
) -> None:
|
|
218
|
+
"""ADR 0033: refused where it is written, not clamped where nobody sees it. A bundle
|
|
219
|
+
asking for ten minutes would otherwise get five, and learn it from a failed run."""
|
|
220
|
+
broken = rules_broken(variant(_connector_timeout("PT600S")), connectors)
|
|
221
|
+
assert "connector-timeout-out-of-bounds" in broken
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_a_connector_timeout_within_the_cap_is_accepted(
|
|
225
|
+
variant: Callable[[Callable[[Path], None]], Path], connectors: Path
|
|
226
|
+
) -> None:
|
|
227
|
+
broken = rules_broken(variant(_connector_timeout("PT120S")), connectors)
|
|
228
|
+
assert "connector-timeout-out-of-bounds" not in broken
|
|
229
|
+
|
|
230
|
+
|
|
187
231
|
def test_malformed_workflow_fails_on_schema(
|
|
188
232
|
variant: Callable[[Callable[[Path], None]], Path], connectors: Path
|
|
189
233
|
) -> None:
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Schema validation, and the worked examples the schemas ship with.
|
|
2
|
+
|
|
3
|
+
`schema/README.md` says the examples are fixtures used by the validator tests.
|
|
4
|
+
This is that claim, made true.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from mkp_contracts import schemas
|
|
13
|
+
|
|
14
|
+
from mkbundle.cli import main
|
|
15
|
+
|
|
16
|
+
EXAMPLES = {
|
|
17
|
+
"workflow-triage-inbound.yaml": "workflow",
|
|
18
|
+
"connector-slack.yaml": "connector",
|
|
19
|
+
"bundle.json": "bundle",
|
|
20
|
+
"report-manifest.json": "report_manifest",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize(("filename", "kind"), sorted(EXAMPLES.items()))
|
|
25
|
+
def test_worked_examples_validate(filename: str, kind: str) -> None:
|
|
26
|
+
path = schemas.schema_dir() / "examples" / filename
|
|
27
|
+
document = schemas.load_document(path)
|
|
28
|
+
|
|
29
|
+
assert schemas.detect_kind(document) == kind
|
|
30
|
+
report = schemas.validate_document(document, kind, path=filename)
|
|
31
|
+
assert report.ok, [str(f) for f in report.failures]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_kind_detection_needs_no_annotation() -> None:
|
|
35
|
+
assert schemas.detect_kind({"steps": []}) == "workflow"
|
|
36
|
+
assert schemas.detect_kind({"actions": {}, "auth": {}}) == "connector"
|
|
37
|
+
assert schemas.detect_kind({"contents": {}, "lock": {}}) == "bundle"
|
|
38
|
+
assert schemas.detect_kind({"run_id": "r", "claims": [], "sources": []}) == "report_manifest"
|
|
39
|
+
assert schemas.detect_kind({"unrelated": True}) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_workflow_repeat_requires_a_bound_and_decidable_loop() -> None:
|
|
43
|
+
document = {
|
|
44
|
+
"schema_version": 1,
|
|
45
|
+
"name": "revision_loop",
|
|
46
|
+
"steps": [
|
|
47
|
+
{
|
|
48
|
+
"id": "review_loop",
|
|
49
|
+
"kind": "repeat",
|
|
50
|
+
"until": "{{ steps.review.result.done == true }}",
|
|
51
|
+
"max_iterations": 3,
|
|
52
|
+
"steps": [
|
|
53
|
+
{"id": "review", "kind": "wait", "duration": "PT1S"},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
report = schemas.validate_document(document, "workflow", path="repeat.yaml")
|
|
60
|
+
assert report.ok, [str(f) for f in report.failures]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_cli_validate_accepts_the_examples() -> None:
|
|
64
|
+
examples = schemas.schema_dir() / "examples"
|
|
65
|
+
assert main(["validate", *[str(p) for p in sorted(examples.iterdir())]]) == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_cli_validate_rejects_a_broken_document(tmp_path: Path) -> None:
|
|
69
|
+
broken = tmp_path / "workflow.yaml"
|
|
70
|
+
broken.write_text("schema_version: 1\nname: no_steps\nsteps: []\n")
|
|
71
|
+
|
|
72
|
+
assert main(["validate", str(broken)]) == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_cli_reports_a_document_it_cannot_place(tmp_path: Path) -> None:
|
|
76
|
+
unknown = tmp_path / "thing.yaml"
|
|
77
|
+
unknown.write_text("some: value\n")
|
|
78
|
+
|
|
79
|
+
assert main(["validate", str(unknown)]) == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_validate_connectors_checks_every_manifest(tmp_path: Path) -> None:
|
|
83
|
+
connector = tmp_path / "alpha"
|
|
84
|
+
connector.mkdir()
|
|
85
|
+
(connector / "connector.yaml").write_text(
|
|
86
|
+
"schema_version: 1\n"
|
|
87
|
+
"name: alpha\n"
|
|
88
|
+
"version: 1\n"
|
|
89
|
+
"title: Alpha\n"
|
|
90
|
+
"auth:\n kind: none\n"
|
|
91
|
+
"actions:\n"
|
|
92
|
+
" ping:\n"
|
|
93
|
+
" summary: Ping\n"
|
|
94
|
+
" method: GET\n"
|
|
95
|
+
" path: /ping\n"
|
|
96
|
+
" effect: read\n",
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert main(["validate-connectors", str(tmp_path)]) == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_validate_connectors_fails_on_empty_directory(tmp_path: Path) -> None:
|
|
104
|
+
assert main(["validate-connectors", str(tmp_path)]) == 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_validate_connectors_refuses_a_url_from_input_that_writes(tmp_path: Path) -> None:
|
|
108
|
+
"""ADR 0032: the builder refuses what the runner would refuse at load."""
|
|
109
|
+
connector = tmp_path / "alpha"
|
|
110
|
+
connector.mkdir()
|
|
111
|
+
(connector / "connector.yaml").write_text(
|
|
112
|
+
"schema_version: 1\n"
|
|
113
|
+
"name: alpha\n"
|
|
114
|
+
"version: 1\n"
|
|
115
|
+
"title: Alpha\n"
|
|
116
|
+
"auth:\n kind: none\n"
|
|
117
|
+
"actions:\n"
|
|
118
|
+
" fetch:\n"
|
|
119
|
+
" summary: Fetch\n"
|
|
120
|
+
" method: POST\n"
|
|
121
|
+
" url_from_input: target\n"
|
|
122
|
+
" effect: read\n",
|
|
123
|
+
encoding="utf-8",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert main(["validate-connectors", str(tmp_path)]) == 1
|
|
@@ -18,3 +18,15 @@ def test_version_prints_the_installed_version(capsys: pytest.CaptureFixture[str]
|
|
|
18
18
|
# parser's `prog` was `mkp`, `--version` printed it, and the test agreed. `mkp` is
|
|
19
19
|
# the product namespace and never a tool's name (README.md §Stack).
|
|
20
20
|
assert out.strip() == f"mkbundle {importlib.metadata.version('mkbundle')}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_errors_speak_in_the_tools_own_name(capsys: pytest.CaptureFixture[str], tmp_path) -> None:
|
|
24
|
+
# The error path missed the 2026-08-07 rename: `--version` said mkbundle while a
|
|
25
|
+
# failed build still said `mkp:`. Same rule, other stream.
|
|
26
|
+
not_a_bundle = tmp_path / "not-a-bundle.tar.gz"
|
|
27
|
+
not_a_bundle.write_bytes(b"not a gzip at all")
|
|
28
|
+
code = main(["inspect", str(not_a_bundle)])
|
|
29
|
+
assert code == 2
|
|
30
|
+
err = capsys.readouterr().err
|
|
31
|
+
assert err.startswith("mkbundle: ")
|
|
32
|
+
assert "mkp:" not in err
|
|
@@ -58,7 +58,7 @@ wheels = [
|
|
|
58
58
|
|
|
59
59
|
[[package]]
|
|
60
60
|
name = "mkbundle"
|
|
61
|
-
version = "
|
|
61
|
+
version = "1.1.21"
|
|
62
62
|
source = { editable = "." }
|
|
63
63
|
dependencies = [
|
|
64
64
|
{ name = "mkp-contracts" },
|
|
@@ -85,7 +85,7 @@ dev = [
|
|
|
85
85
|
|
|
86
86
|
[[package]]
|
|
87
87
|
name = "mkp-contracts"
|
|
88
|
-
version = "
|
|
88
|
+
version = "1.1.21"
|
|
89
89
|
source = { editable = "../../packages/mkp-contracts" }
|
|
90
90
|
dependencies = [
|
|
91
91
|
{ name = "jsonschema" },
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""Schema validation, and the worked examples the schemas ship with.
|
|
2
|
-
|
|
3
|
-
`schema/README.md` says the examples are fixtures used by the validator tests.
|
|
4
|
-
This is that claim, made true.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
from mkp_contracts import schemas
|
|
13
|
-
|
|
14
|
-
from mkbundle.cli import main
|
|
15
|
-
|
|
16
|
-
EXAMPLES = {
|
|
17
|
-
"workflow-triage-inbound.yaml": "workflow",
|
|
18
|
-
"connector-slack.yaml": "connector",
|
|
19
|
-
"bundle.json": "bundle",
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@pytest.mark.parametrize(("filename", "kind"), sorted(EXAMPLES.items()))
|
|
24
|
-
def test_worked_examples_validate(filename: str, kind: str) -> None:
|
|
25
|
-
path = schemas.schema_dir() / "examples" / filename
|
|
26
|
-
document = schemas.load_document(path)
|
|
27
|
-
|
|
28
|
-
assert schemas.detect_kind(document) == kind
|
|
29
|
-
report = schemas.validate_document(document, kind, path=filename)
|
|
30
|
-
assert report.ok, [str(f) for f in report.failures]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_kind_detection_needs_no_annotation() -> None:
|
|
34
|
-
assert schemas.detect_kind({"steps": []}) == "workflow"
|
|
35
|
-
assert schemas.detect_kind({"actions": {}, "auth": {}}) == "connector"
|
|
36
|
-
assert schemas.detect_kind({"contents": {}, "lock": {}}) == "bundle"
|
|
37
|
-
assert schemas.detect_kind({"unrelated": True}) is None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_cli_validate_accepts_the_examples() -> None:
|
|
41
|
-
examples = schemas.schema_dir() / "examples"
|
|
42
|
-
assert main(["validate", *[str(p) for p in sorted(examples.iterdir())]]) == 0
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_cli_validate_rejects_a_broken_document(tmp_path: Path) -> None:
|
|
46
|
-
broken = tmp_path / "workflow.yaml"
|
|
47
|
-
broken.write_text("schema_version: 1\nname: no_steps\nsteps: []\n")
|
|
48
|
-
|
|
49
|
-
assert main(["validate", str(broken)]) == 1
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_cli_reports_a_document_it_cannot_place(tmp_path: Path) -> None:
|
|
53
|
-
unknown = tmp_path / "thing.yaml"
|
|
54
|
-
unknown.write_text("some: value\n")
|
|
55
|
-
|
|
56
|
-
assert main(["validate", str(unknown)]) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|