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/__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
|