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 ADDED
@@ -0,0 +1,12 @@
1
+ """mkbundle — build and validate the artifacts this platform deploys.
2
+
3
+ Bundles are immutable, content-addressed and signed (ADR 0008). A bundle that
4
+ fails validation never becomes a deployment candidate, so this tool is the gate
5
+ between authoring and everything downstream.
6
+
7
+ Named for what it makes rather than for the platform: `mkp` is the platform, and
8
+ that name belongs to its roles, its settings and its services rather than to one of
9
+ its tools.
10
+ """
11
+
12
+ __version__ = "0.1.0"
mkbundle/build.py ADDED
@@ -0,0 +1,286 @@
1
+ """Building a bundle: validate everything, then emit an archive identified by its digest.
2
+
3
+ The order matters. A bundle that fails validation never becomes a deployment
4
+ candidate (ADR 0008), so nothing is written until every rule has passed. The
5
+ manifest is assembled from what the build observed — file digests, the toolchain
6
+ versions it validated with — and its digest is the bundle's identity.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import io
13
+ import json
14
+ import tarfile
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from mkp_contracts import schemas
21
+ from mkp_contracts.archive import MANIFEST_NAME, manifest_of
22
+ from mkp_contracts.canonical import canonical_bytes, file_digest, manifest_digest
23
+ from mkp_contracts.errors import ContractError
24
+
25
+ from . import catalog as catalog_module
26
+ from . import rules, signing, source, toolchain
27
+ from .errors import BuildError, Report
28
+
29
+
30
+ @dataclass
31
+ class BuildResult:
32
+ manifest: dict[str, Any]
33
+ digest: str
34
+ report: Report
35
+ archive: Path | None = None
36
+
37
+
38
+ def build(
39
+ src: Path,
40
+ *,
41
+ connectors_dir: Path | None = None,
42
+ output: Path | None = None,
43
+ created: datetime | None = None,
44
+ sign_key: Path | None = None,
45
+ public_key: Path | None = None,
46
+ ) -> BuildResult:
47
+ bundle = source.load(src)
48
+ report = Report()
49
+
50
+ catalog = None
51
+ if connectors_dir is not None:
52
+ catalog = catalog_module.load(connectors_dir)
53
+ else:
54
+ report.skipped_rules.append(
55
+ "unknown-action, connector-version-mismatch, missing-idempotency-key — "
56
+ "no connector catalog given (--connectors)"
57
+ )
58
+ if sign_key is None:
59
+ report.skipped_rules.append(
60
+ "signing — no key given (--sign-key); a runner refuses an unsigned bundle"
61
+ )
62
+
63
+ machine_names = rules.machine_paths(bundle.machines)
64
+ declared = bundle.requires
65
+ declared_connections = set(declared.get("connections") or [])
66
+ declared_secrets = set(declared.get("secrets") or [])
67
+
68
+ seen_names: dict[str, str] = {}
69
+ for relative in bundle.workflows:
70
+ path = relative.as_posix()
71
+ document = schemas.load_document(bundle.root / relative)
72
+ shape = schemas.validate_document(document, "workflow", path=path)
73
+ report.extend(shape)
74
+ if not shape.ok or not isinstance(document, dict):
75
+ continue
76
+
77
+ name = document.get("name", "")
78
+ if name in seen_names:
79
+ report.fail(
80
+ "duplicate-workflow-name",
81
+ f"workflow name {name!r} is already used by {seen_names[name]}; "
82
+ "names are unique within a project",
83
+ path=path,
84
+ )
85
+ else:
86
+ seen_names[name] = path
87
+
88
+ report.extend(
89
+ rules.check_workflow(
90
+ document,
91
+ path,
92
+ machines=machine_names,
93
+ declared_connections=declared_connections,
94
+ declared_secrets=declared_secrets,
95
+ connector_pins=bundle.connector_pins,
96
+ catalog=catalog,
97
+ )
98
+ )
99
+
100
+ specs = {}
101
+ for relative in bundle.machines:
102
+ text = (bundle.root / relative).read_text()
103
+ report.extend(rules.check_machine_gates(text, relative.as_posix()))
104
+ spec = rules.read_spec_version(text)
105
+ if spec is None:
106
+ report.fail(
107
+ "spec-missing",
108
+ "machine declares no `mklang:` spec version",
109
+ path=relative.as_posix(),
110
+ )
111
+ else:
112
+ specs[relative.as_posix()] = spec
113
+ report.extend(rules.check_spec_versions(specs))
114
+ report.extend(toolchain.check(bundle.machines, bundle.root))
115
+
116
+ manifest = _assemble(bundle, specs, created=created)
117
+ report.extend(schemas.validate_document(manifest, "bundle", path=MANIFEST_NAME))
118
+
119
+ digest = manifest_digest(manifest)
120
+ if not report.ok:
121
+ return BuildResult(manifest=manifest, digest=digest, report=report)
122
+
123
+ if sign_key is not None:
124
+ if public_key is None:
125
+ raise BuildError("--sign-key needs --public-key: the key id is read off the public key")
126
+ # Signing after the digest, and it does not move it: `signature` is outside
127
+ # the canonical form, so a bundle keeps its identity when it gains one.
128
+ manifest["signature"] = signing.sign(
129
+ canonical_bytes(manifest), sign_key, public_key, digest=digest
130
+ )
131
+ if manifest_digest(manifest) != digest:
132
+ # Raised rather than asserted (`H3`): `python -O` removes an `assert`, and this
133
+ # is the one line standing between a signed bundle and a digest that names
134
+ # something else. `signature` is excluded from the canonical form precisely so
135
+ # that this cannot happen, which makes it the invariant worth paying for.
136
+ raise BuildError(
137
+ "signing moved the bundle's digest, which must never happen: "
138
+ "`signature` is excluded from the canonical form (ADR 0008)"
139
+ )
140
+
141
+ archive = None
142
+ if output is not None:
143
+ archive = _write_archive(bundle, manifest, digest, output)
144
+ return BuildResult(manifest=manifest, digest=digest, report=report, archive=archive)
145
+
146
+
147
+ def verify(archive: Path, public_key: Path) -> Report:
148
+ """Check a built bundle: its signature, and every file against its recorded digest.
149
+
150
+ Verify-at-load is the runner's (C3) and is not this. This exists so the tool can
151
+ prove its own output rather than assert it.
152
+ """
153
+ report = Report()
154
+ manifest = read_archive(archive)
155
+
156
+ signature = manifest.get("signature")
157
+ if not signature:
158
+ report.fail("unsigned", "the bundle carries no signature; a runner refuses it (ADR 0008)")
159
+ else:
160
+ report.extend(signing.verify(canonical_bytes(manifest), signature, public_key))
161
+
162
+ with tarfile.open(archive, mode="r:gz") as tar:
163
+ for group in ("workflows", "machines", "assets"):
164
+ for entry in manifest["contents"].get(group, []):
165
+ member = tar.extractfile(entry["path"])
166
+ if member is None:
167
+ report.fail(
168
+ "content-missing", "listed in the manifest but absent", path=entry["path"]
169
+ )
170
+ continue
171
+ if file_digest(member.read()) != entry["sha256"]:
172
+ report.fail(
173
+ "content-digest",
174
+ "content does not match the digest the manifest records",
175
+ path=entry["path"],
176
+ )
177
+ return report
178
+
179
+
180
+ def _assemble(
181
+ bundle: source.BundleSource,
182
+ specs: dict[str, str],
183
+ *,
184
+ created: datetime | None,
185
+ ) -> dict[str, Any]:
186
+ spec_versions = sorted(set(specs.values()))
187
+ if not spec_versions:
188
+ declared_spec = bundle.declared.get("lock", {}).get("spec")
189
+ if not declared_spec:
190
+ raise BuildError(
191
+ "the bundle has no machines, so `lock.spec` cannot be observed — "
192
+ "declare it in bundle.yaml or add a machine"
193
+ )
194
+ spec_versions = [str(declared_spec)]
195
+
196
+ contents: dict[str, Any] = {
197
+ "workflows": [_file_entry(bundle, p) for p in bundle.workflows],
198
+ "machines": [_file_entry(bundle, p) for p in bundle.machines],
199
+ }
200
+ if bundle.assets:
201
+ contents["assets"] = [_file_entry(bundle, p) for p in bundle.assets]
202
+
203
+ stamp = created or datetime.now(UTC)
204
+ manifest: dict[str, Any] = {
205
+ "schema_version": 1,
206
+ "project": bundle.project,
207
+ "created": stamp.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
208
+ "contents": contents,
209
+ "lock": {
210
+ "mklang": toolchain.version(),
211
+ "spec": spec_versions[0],
212
+ "connectors": bundle.connector_pins,
213
+ },
214
+ }
215
+ if "source" in bundle.declared:
216
+ manifest["source"] = bundle.declared["source"]
217
+ if bundle.requires:
218
+ manifest["requires"] = bundle.requires
219
+ return manifest
220
+
221
+
222
+ def _file_entry(bundle: source.BundleSource, relative: Path) -> dict[str, str]:
223
+ return {"path": relative.as_posix(), "sha256": file_digest(bundle.read(relative))}
224
+
225
+
226
+ def _write_archive(
227
+ bundle: source.BundleSource, manifest: dict[str, Any], digest: str, output: Path
228
+ ) -> Path:
229
+ """A deterministic tar.gz: same content in, same bytes out.
230
+
231
+ Identity is the manifest digest, not the archive's, but a reproducible
232
+ archive is what makes "nothing is rebuilt on promote" checkable rather than
233
+ merely asserted.
234
+ """
235
+ target = output / f"{bundle.project}-{digest[:12]}.tar.gz" if output.is_dir() else output
236
+ target.parent.mkdir(parents=True, exist_ok=True)
237
+
238
+ full_manifest = json.dumps(
239
+ manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=False
240
+ ).encode("utf-8")
241
+
242
+ buffer = io.BytesIO()
243
+ with tarfile.open(fileobj=buffer, mode="w") as tar:
244
+ _add(tar, MANIFEST_NAME, full_manifest)
245
+ for entry in _all_files(manifest):
246
+ _add(tar, entry, bundle.read(Path(entry)))
247
+
248
+ # filename="" keeps the output file's own name out of the gzip header, which
249
+ # would otherwise make two builds of identical content differ.
250
+ with (
251
+ open(target, "wb") as handle,
252
+ gzip.GzipFile(filename="", fileobj=handle, mode="wb", mtime=0) as gz,
253
+ ):
254
+ gz.write(buffer.getvalue())
255
+ return target
256
+
257
+
258
+ def _all_files(manifest: dict[str, Any]) -> list[str]:
259
+ contents = manifest["contents"]
260
+ paths: list[str] = []
261
+ for group in ("workflows", "machines", "assets"):
262
+ paths.extend(entry["path"] for entry in contents.get(group, []))
263
+ return sorted(paths)
264
+
265
+
266
+ def _add(tar: tarfile.TarFile, name: str, data: bytes) -> None:
267
+ info = tarfile.TarInfo(name)
268
+ info.size = len(data)
269
+ info.mtime = 0
270
+ info.mode = 0o644
271
+ info.uid = info.gid = 0
272
+ info.uname = info.gname = ""
273
+ tar.addfile(info, io.BytesIO(data))
274
+
275
+
276
+ def read_archive(path: Path) -> dict[str, Any]:
277
+ """The manifest inside a built bundle.
278
+
279
+ Delegates to `mkp_contracts`, which the control plane also uses to decide whether an
280
+ uploaded archive belongs under the digest it was filed as. One reader, so the two
281
+ cannot disagree about what a bundle contains.
282
+ """
283
+ try:
284
+ return manifest_of(path.read_bytes())
285
+ except ContractError as exc:
286
+ raise BuildError(f"{path}: {exc}") from exc
mkbundle/catalog.py ADDED
@@ -0,0 +1,48 @@
1
+ """The connector catalog a build validates workflow steps against.
2
+
3
+ A connector is a document, not a module (ADR 0007). The catalog is a directory of
4
+ them — `<dir>/<name>/connector.yaml`, or `<dir>/<name>.yaml` — which is what lets
5
+ a build answer "does this action exist, and does it write?" without importing
6
+ anything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from .errors import BuildError
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Connector:
22
+ name: str
23
+ version: int
24
+ actions: dict[str, dict[str, Any]]
25
+ path: Path
26
+
27
+ def action(self, name: str) -> dict[str, Any] | None:
28
+ return self.actions.get(name)
29
+
30
+
31
+ def load(directory: Path) -> dict[str, Connector]:
32
+ directory = directory.resolve()
33
+ if not directory.is_dir():
34
+ raise BuildError(f"{directory}: connector catalog is not a directory")
35
+
36
+ catalog: dict[str, Connector] = {}
37
+ for path in sorted([*directory.glob("*/connector.yaml"), *directory.glob("*.yaml")]):
38
+ document = yaml.safe_load(path.read_text()) or {}
39
+ name = document.get("name")
40
+ if not name:
41
+ raise BuildError(f"{path}: connector manifest has no `name`")
42
+ catalog[name] = Connector(
43
+ name=name,
44
+ version=document.get("version", 0),
45
+ actions=document.get("actions", {}) or {},
46
+ path=path,
47
+ )
48
+ return catalog
mkbundle/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """The `mkp` command line.
2
+
3
+ Three verbs: validate a document, build a bundle, inspect a built one. One tool
4
+ rather than a builder and a separate validator, because the rules would otherwise
5
+ be written twice and drift once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import importlib.metadata
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from mkp_contracts import schemas
17
+ from mkp_contracts.canonical import manifest_digest
18
+
19
+ from . import build as build_module
20
+ from .errors import BuildError, Report
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ # `mkp` is the product namespace and deliberately not the name of any one tool
25
+ # (README.md §Stack); the binary is `mkbundle` and `--version` prints `%(prog)s`, so
26
+ # this said the wrong name in `--help` and in `--version` both.
27
+ parser = argparse.ArgumentParser(prog="mkbundle", description=(__doc__ or "").splitlines()[0])
28
+ parser.add_argument(
29
+ "--version",
30
+ action="version",
31
+ version=f"%(prog)s {importlib.metadata.version('mkbundle')}",
32
+ )
33
+ parser.add_argument("--format", choices=("text", "json"), default="text")
34
+ subparsers = parser.add_subparsers(dest="command", required=True)
35
+
36
+ validate = subparsers.add_parser(
37
+ "validate", help="validate a workflow, connector manifest, or bundle manifest"
38
+ )
39
+ validate.add_argument("paths", nargs="+", type=Path)
40
+ validate.add_argument(
41
+ "--kind",
42
+ choices=schemas.KINDS,
43
+ help="skip detection and validate against this schema",
44
+ )
45
+
46
+ build = subparsers.add_parser("build", help="validate a bundle source tree and archive it")
47
+ build.add_argument("src", type=Path)
48
+ build.add_argument(
49
+ "-o",
50
+ "--output",
51
+ type=Path,
52
+ help="archive path, or a directory to name it in. Omit to validate without writing",
53
+ )
54
+ build.add_argument(
55
+ "--connectors",
56
+ type=Path,
57
+ help="connector catalog. Without it the action and idempotency rules cannot run",
58
+ )
59
+ build.add_argument("--sign-key", type=Path, help="minisign secret key, unencrypted")
60
+ build.add_argument("--public-key", type=Path, help="its public key, for the key id")
61
+
62
+ inspect = subparsers.add_parser("inspect", help="print the manifest of a built bundle")
63
+ inspect.add_argument("archive", type=Path)
64
+
65
+ verify = subparsers.add_parser(
66
+ "verify", help="check a built bundle's signature and its contents against the manifest"
67
+ )
68
+ verify.add_argument("archive", type=Path)
69
+ verify.add_argument("--public-key", type=Path, required=True)
70
+
71
+ args = parser.parse_args(argv)
72
+ try:
73
+ if args.command == "validate":
74
+ return _validate(args)
75
+ if args.command == "build":
76
+ return _build(args)
77
+ if args.command == "verify":
78
+ return _verify(args)
79
+ return _inspect(args)
80
+ except BuildError as exc:
81
+ _emit_error(str(exc), args.format)
82
+ return 2
83
+
84
+
85
+ def _validate(args: argparse.Namespace) -> int:
86
+ report = Report()
87
+ for path in args.paths:
88
+ if not path.is_file():
89
+ report.fail("not-found", "no such file", path=str(path))
90
+ continue
91
+ document = schemas.load_document(path)
92
+ kind = args.kind or schemas.detect_kind(document)
93
+ if kind is None:
94
+ report.fail(
95
+ "unknown-kind",
96
+ "cannot tell which contract this document is against; pass --kind",
97
+ path=str(path),
98
+ )
99
+ continue
100
+ report.extend(schemas.validate_document(document, kind, path=str(path)))
101
+ return _report(report, args.format, ok_message=f"{len(args.paths)} document(s) valid")
102
+
103
+
104
+ def _build(args: argparse.Namespace) -> int:
105
+ connectors = args.connectors
106
+ if connectors is None:
107
+ default = Path.cwd() / "connectors"
108
+ if (default / "README.md").exists() and any(default.glob("*/connector.yaml")):
109
+ connectors = default
110
+
111
+ result = build_module.build(
112
+ args.src,
113
+ connectors_dir=connectors,
114
+ output=args.output,
115
+ sign_key=args.sign_key,
116
+ public_key=args.public_key,
117
+ )
118
+
119
+ if args.format == "json":
120
+ print(
121
+ json.dumps(
122
+ {
123
+ "ok": result.report.ok,
124
+ "digest": result.digest,
125
+ "archive": str(result.archive) if result.archive else None,
126
+ "skipped_rules": result.report.skipped_rules,
127
+ "failures": [f.as_dict() for f in result.report.failures],
128
+ },
129
+ indent=2,
130
+ )
131
+ )
132
+ return 0 if result.report.ok else 1
133
+
134
+ for rule in result.report.skipped_rules:
135
+ print(f"not checked: {rule}", file=sys.stderr)
136
+ if not result.report.ok:
137
+ _print_failures(result.report)
138
+ return 1
139
+ print(f"digest {result.digest}")
140
+ if result.archive:
141
+ print(f"archive {result.archive}")
142
+ return 0
143
+
144
+
145
+ def _verify(args: argparse.Namespace) -> int:
146
+ report = build_module.verify(args.archive, args.public_key)
147
+ return _report(
148
+ report, args.format, ok_message=f"{args.archive.name}: signature and contents ok"
149
+ )
150
+
151
+
152
+ def _inspect(args: argparse.Namespace) -> int:
153
+ manifest = build_module.read_archive(args.archive)
154
+ if args.format == "json":
155
+ print(json.dumps({"digest": manifest_digest(manifest), "manifest": manifest}, indent=2))
156
+ return 0
157
+
158
+ print(f"digest {manifest_digest(manifest)}")
159
+ print(f"project {manifest['project']}")
160
+ print(f"created {manifest.get('created', '-')}")
161
+ lock = manifest["lock"]
162
+ print(f"mklang {lock['mklang']} (spec {lock['spec']})")
163
+ for name, version in sorted(lock.get("connectors", {}).items()):
164
+ print(f" pin {name} v{version}")
165
+ for group in ("workflows", "machines", "assets"):
166
+ for entry in manifest["contents"].get(group, []):
167
+ print(f" {group[:-1]:<9} {entry['path']}")
168
+ signature = manifest.get("signature")
169
+ print(f"signed {signature['algorithm'] + ' ' + signature['key_id'] if signature else 'no'}")
170
+ return 0
171
+
172
+
173
+ def _report(report: Report, output_format: str, *, ok_message: str) -> int:
174
+ if output_format == "json":
175
+ print(
176
+ json.dumps(
177
+ {"ok": report.ok, "failures": [f.as_dict() for f in report.failures]}, indent=2
178
+ )
179
+ )
180
+ return 0 if report.ok else 1
181
+ if report.ok:
182
+ print(ok_message)
183
+ return 0
184
+ _print_failures(report)
185
+ return 1
186
+
187
+
188
+ def _print_failures(report: Report) -> None:
189
+ for failure in report.failures:
190
+ print(str(failure), file=sys.stderr)
191
+ print(f"\n{len(report.failures)} problem(s)", file=sys.stderr)
192
+
193
+
194
+ def _emit_error(message: str, output_format: str) -> None:
195
+ if output_format == "json":
196
+ print(json.dumps({"ok": False, "error": message}, indent=2))
197
+ else:
198
+ print(f"mkp: {message}", file=sys.stderr)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())
mkbundle/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Build-specific failures, over the shared reporting vocabulary.
2
+
3
+ `Failure` and `Report` belong to `mkp_contracts`: reporting every rule a document
4
+ breaks rather than the first is a property of checking a contract, not of building
5
+ something. What is local here is the one exception that means *the build cannot
6
+ proceed at all*, as opposed to a rule the artifact fails.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from mkp_contracts.errors import ContractError, Failure, Report
12
+
13
+ __all__ = ["BuildError", "Failure", "Report"]
14
+
15
+
16
+ class BuildError(ContractError):
17
+ """Something stopped the build before rules could be evaluated at all."""
mkbundle/py.typed ADDED
File without changes