specmint 0.1.0a2__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.
- opsdevcode_specmint/__init__.py +7 -0
- opsdevcode_specmint/__main__.py +10 -0
- opsdevcode_specmint/automation.py +158 -0
- opsdevcode_specmint/cli.py +79 -0
- opsdevcode_specmint/compiler.py +38 -0
- opsdevcode_specmint/contracts.py +160 -0
- opsdevcode_specmint/errors.py +62 -0
- opsdevcode_specmint/identity.py +13 -0
- opsdevcode_specmint/integration/__init__.py +11 -0
- opsdevcode_specmint/integration/__main__.py +8 -0
- opsdevcode_specmint/integration/canonical.py +87 -0
- opsdevcode_specmint/integration/framing.py +85 -0
- opsdevcode_specmint/integration/harness.py +58 -0
- opsdevcode_specmint/integration/models.py +189 -0
- opsdevcode_specmint/integration/process.py +123 -0
- opsdevcode_specmint/integration/reference_server.py +303 -0
- opsdevcode_specmint/integration/resolve.py +112 -0
- opsdevcode_specmint/integration/schemas/mint.integration.v0.json +53 -0
- opsdevcode_specmint/markdown_spec.py +155 -0
- opsdevcode_specmint/mint/__init__.py +20 -0
- opsdevcode_specmint/mint/adapters/__init__.py +23 -0
- opsdevcode_specmint/mint/adapters/output.py +82 -0
- opsdevcode_specmint/mint/adapters/plan.py +232 -0
- opsdevcode_specmint/mint/adapters/registry.py +100 -0
- opsdevcode_specmint/mint/adapters/repository.py +695 -0
- opsdevcode_specmint/mint/adapters/sandbox.py +103 -0
- opsdevcode_specmint/mint/adapters/snapshot.py +401 -0
- opsdevcode_specmint/mint/adapters/types.py +238 -0
- opsdevcode_specmint/mint/ast.py +86 -0
- opsdevcode_specmint/mint/catalog.py +106 -0
- opsdevcode_specmint/mint/check.py +239 -0
- opsdevcode_specmint/mint/cli.py +593 -0
- opsdevcode_specmint/mint/compile.py +114 -0
- opsdevcode_specmint/mint/conformance.py +113 -0
- opsdevcode_specmint/mint/convert.py +736 -0
- opsdevcode_specmint/mint/errors.py +93 -0
- opsdevcode_specmint/mint/extensions.py +81 -0
- opsdevcode_specmint/mint/fmt.py +108 -0
- opsdevcode_specmint/mint/host.py +39 -0
- opsdevcode_specmint/mint/inputs.py +169 -0
- opsdevcode_specmint/mint/inspect_ir.py +38 -0
- opsdevcode_specmint/mint/integrations_cmd.py +187 -0
- opsdevcode_specmint/mint/ir.py +253 -0
- opsdevcode_specmint/mint/lexer.py +157 -0
- opsdevcode_specmint/mint/lower.py +43 -0
- opsdevcode_specmint/mint/lsp/__init__.py +5 -0
- opsdevcode_specmint/mint/lsp/features.py +515 -0
- opsdevcode_specmint/mint/lsp/overlay.py +98 -0
- opsdevcode_specmint/mint/lsp/protocol.py +55 -0
- opsdevcode_specmint/mint/lsp/server.py +279 -0
- opsdevcode_specmint/mint/lsp/utf16.py +49 -0
- opsdevcode_specmint/mint/parser.py +429 -0
- opsdevcode_specmint/mint/project.py +793 -0
- opsdevcode_specmint/mint/resolve.py +329 -0
- opsdevcode_specmint/parse.py +277 -0
- opsdevcode_specmint/pins.py +39 -0
- opsdevcode_specmint/py.typed +0 -0
- opsdevcode_specmint/strict_load.py +50 -0
- opsdevcode_specmint/version.py +29 -0
- specmint-0.1.0a2.dist-info/METADATA +125 -0
- specmint-0.1.0a2.dist-info/RECORD +66 -0
- specmint-0.1.0a2.dist-info/WHEEL +5 -0
- specmint-0.1.0a2.dist-info/entry_points.txt +4 -0
- specmint-0.1.0a2.dist-info/licenses/LICENSE +200 -0
- specmint-0.1.0a2.dist-info/licenses/NOTICE +7 -0
- specmint-0.1.0a2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""AutomationSpecification and AutomationIntent contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opsdevcode_specmint.contracts import SpecContract
|
|
9
|
+
from opsdevcode_specmint.errors import CompilationFailureError, SpecUnsupportedError
|
|
10
|
+
from opsdevcode_specmint.identity import revision_digest
|
|
11
|
+
from opsdevcode_specmint.pins import (
|
|
12
|
+
AUTOMATION_API_VERSION,
|
|
13
|
+
AUTOMATION_INTENT_KIND,
|
|
14
|
+
AUTOMATION_SPEC_KIND,
|
|
15
|
+
LOCAL_AUTOMATION_TYPE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
AUTOMATION_SPEC_V1ALPHA1 = SpecContract(AUTOMATION_API_VERSION, AUTOMATION_SPEC_KIND)
|
|
19
|
+
AUTOMATION_INTENT_V1ALPHA1 = SpecContract(AUTOMATION_API_VERSION, AUTOMATION_INTENT_KIND)
|
|
20
|
+
|
|
21
|
+
_FORBIDDEN_KEY_PARTS = ("password", "secret", "token", "credential", "url", "http")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_automation_document(document: dict[str, Any]) -> bool:
|
|
25
|
+
return (
|
|
26
|
+
document.get("apiVersion") == AUTOMATION_API_VERSION
|
|
27
|
+
and document.get("kind") == AUTOMATION_SPEC_KIND
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_automation_intent(document: dict[str, Any]) -> bool:
|
|
32
|
+
return (
|
|
33
|
+
document.get("apiVersion") == AUTOMATION_API_VERSION
|
|
34
|
+
and document.get("kind") == AUTOMATION_INTENT_KIND
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def reject_forbidden_fields(value: Any, *, path: str = "document") -> None:
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
for key, item in value.items():
|
|
41
|
+
lowered = str(key).lower()
|
|
42
|
+
if any(part in lowered for part in _FORBIDDEN_KEY_PARTS):
|
|
43
|
+
raise SpecUnsupportedError(
|
|
44
|
+
f"remove {path}.{key}; credentials and URLs are not accepted in specifications"
|
|
45
|
+
)
|
|
46
|
+
reject_forbidden_fields(item, path=f"{path}.{key}")
|
|
47
|
+
return
|
|
48
|
+
if isinstance(value, list):
|
|
49
|
+
for index, item in enumerate(value):
|
|
50
|
+
reject_forbidden_fields(item, path=f"{path}[{index}]")
|
|
51
|
+
return
|
|
52
|
+
if isinstance(value, str) and value.lower().startswith(("http://", "https://")):
|
|
53
|
+
raise SpecUnsupportedError(
|
|
54
|
+
f"remove URL at {path}; AutomationSpecification placement is a sandbox id"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class AutomationIntent:
|
|
60
|
+
identity_id: str
|
|
61
|
+
revision: str
|
|
62
|
+
owner: str
|
|
63
|
+
statement: str
|
|
64
|
+
automation_type: str
|
|
65
|
+
automation_version: str
|
|
66
|
+
sandbox: str
|
|
67
|
+
required_evidence: tuple[str, ...]
|
|
68
|
+
require_authorization: bool
|
|
69
|
+
status: str
|
|
70
|
+
|
|
71
|
+
def to_canonical_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"apiVersion": AUTOMATION_API_VERSION,
|
|
74
|
+
"kind": AUTOMATION_INTENT_KIND,
|
|
75
|
+
"identity": {"id": self.identity_id, "revision": self.revision},
|
|
76
|
+
"source": {
|
|
77
|
+
"apiVersion": AUTOMATION_API_VERSION,
|
|
78
|
+
"kind": AUTOMATION_SPEC_KIND,
|
|
79
|
+
"id": self.identity_id,
|
|
80
|
+
},
|
|
81
|
+
"intent": {
|
|
82
|
+
"owner": self.owner,
|
|
83
|
+
"statement": self.statement,
|
|
84
|
+
"automation": {
|
|
85
|
+
"type": self.automation_type,
|
|
86
|
+
"version": self.automation_version,
|
|
87
|
+
},
|
|
88
|
+
"placement": {"sandbox": self.sandbox},
|
|
89
|
+
"required_evidence": list(self.required_evidence),
|
|
90
|
+
"constraints": {
|
|
91
|
+
"require_authorization": self.require_authorization,
|
|
92
|
+
"allow_platform_mutation": False,
|
|
93
|
+
},
|
|
94
|
+
"status": self.status,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_automation_intent(closed: dict[str, Any]) -> AutomationIntent:
|
|
100
|
+
projected = _project_closed_automation(closed)
|
|
101
|
+
unsigned = replace(projected, revision="")
|
|
102
|
+
body = unsigned.to_canonical_dict()
|
|
103
|
+
del body["identity"]["revision"]
|
|
104
|
+
return replace(projected, revision=revision_digest(body))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _project_closed_automation(closed: dict[str, Any]) -> AutomationIntent:
|
|
108
|
+
spec = closed.get("spec")
|
|
109
|
+
metadata = closed.get("metadata")
|
|
110
|
+
if not isinstance(spec, dict) or not isinstance(metadata, dict):
|
|
111
|
+
raise CompilationFailureError("Closed automation is missing metadata or spec.")
|
|
112
|
+
spec_id = metadata.get("id")
|
|
113
|
+
owner = spec.get("owner")
|
|
114
|
+
statement = spec.get("intent")
|
|
115
|
+
automation = spec.get("automation")
|
|
116
|
+
placement = spec.get("placement")
|
|
117
|
+
constraints = spec.get("constraints")
|
|
118
|
+
evidence = spec.get("required_evidence")
|
|
119
|
+
status = spec.get("status")
|
|
120
|
+
if not isinstance(spec_id, str) or not spec_id:
|
|
121
|
+
raise CompilationFailureError("Closed automation is missing metadata.id.")
|
|
122
|
+
if not isinstance(owner, str) or not isinstance(statement, str):
|
|
123
|
+
raise CompilationFailureError("Closed automation is missing owner or intent.")
|
|
124
|
+
if not isinstance(automation, dict) or not isinstance(placement, dict):
|
|
125
|
+
raise CompilationFailureError("Closed automation is missing automation or placement.")
|
|
126
|
+
if not isinstance(constraints, dict):
|
|
127
|
+
raise CompilationFailureError("Closed automation is missing constraints.")
|
|
128
|
+
automation_type = automation.get("type")
|
|
129
|
+
automation_version = automation.get("version")
|
|
130
|
+
sandbox = placement.get("sandbox")
|
|
131
|
+
require_auth = constraints.get("require_authorization")
|
|
132
|
+
allow_mutation = constraints.get("allow_platform_mutation")
|
|
133
|
+
if automation_type != LOCAL_AUTOMATION_TYPE or automation_version != "v1alpha1":
|
|
134
|
+
raise SpecUnsupportedError(
|
|
135
|
+
f"Only {LOCAL_AUTOMATION_TYPE} v1alpha1 is accepted in this slice."
|
|
136
|
+
)
|
|
137
|
+
if not isinstance(sandbox, str) or not sandbox:
|
|
138
|
+
raise CompilationFailureError("Closed automation placement.sandbox is incomplete.")
|
|
139
|
+
if require_auth is not True or allow_mutation is not False:
|
|
140
|
+
raise CompilationFailureError(
|
|
141
|
+
"Closed automation must require authorization and forbid platform mutation."
|
|
142
|
+
)
|
|
143
|
+
if not isinstance(status, str):
|
|
144
|
+
raise CompilationFailureError("Closed automation status is incomplete.")
|
|
145
|
+
if not isinstance(evidence, list) or not all(isinstance(item, str) for item in evidence):
|
|
146
|
+
raise CompilationFailureError("Closed automation required_evidence is incomplete.")
|
|
147
|
+
return AutomationIntent(
|
|
148
|
+
identity_id=spec_id,
|
|
149
|
+
revision="",
|
|
150
|
+
owner=owner,
|
|
151
|
+
statement=statement,
|
|
152
|
+
automation_type=automation_type,
|
|
153
|
+
automation_version=automation_version,
|
|
154
|
+
sandbox=sandbox,
|
|
155
|
+
required_evidence=tuple(evidence),
|
|
156
|
+
require_authorization=True,
|
|
157
|
+
status=status,
|
|
158
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Public CLI: mint language plus a thin specmint alias. No serve, execute, or apply."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, Any, TextIO
|
|
12
|
+
|
|
13
|
+
from opsdevcode_specmint import __version__
|
|
14
|
+
from opsdevcode_specmint.compiler import inspect_artifact, validate_specification
|
|
15
|
+
from opsdevcode_specmint.errors import SpecProblem
|
|
16
|
+
from opsdevcode_specmint.parse import decode_body, decode_path, media_type_for_format
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(
|
|
20
|
+
argv: Sequence[str] | None = None,
|
|
21
|
+
*,
|
|
22
|
+
stdin: IO[bytes] | None = None,
|
|
23
|
+
stdout: TextIO | None = None,
|
|
24
|
+
stderr: TextIO | None = None,
|
|
25
|
+
) -> int:
|
|
26
|
+
out = stdout or sys.stdout
|
|
27
|
+
err = stderr or sys.stderr
|
|
28
|
+
parser = _build_parser()
|
|
29
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
30
|
+
if args.show_version or args.command == "version":
|
|
31
|
+
out.write(f"specmint {__version__}\n")
|
|
32
|
+
return 0
|
|
33
|
+
if args.command is None:
|
|
34
|
+
parser.print_help(out)
|
|
35
|
+
return 0
|
|
36
|
+
try:
|
|
37
|
+
if args.command == "mint":
|
|
38
|
+
from opsdevcode_specmint.mint.cli import main as mint_main
|
|
39
|
+
|
|
40
|
+
raw_in = b"" if stdin is None else stdin.read()
|
|
41
|
+
text = raw_in.decode("utf-8") if isinstance(raw_in, bytes | bytearray) else str(raw_in)
|
|
42
|
+
return mint_main(list(args.mint_args), stdin=io.StringIO(text), stdout=out, stderr=err)
|
|
43
|
+
document = _load_document(args.path, stdin=stdin, format=getattr(args, "format", None))
|
|
44
|
+
if args.command == "validate":
|
|
45
|
+
payload = validate_specification(document)
|
|
46
|
+
elif args.command == "compile":
|
|
47
|
+
from opsdevcode_specmint.compiler import compile_specification
|
|
48
|
+
|
|
49
|
+
payload = compile_specification(document)
|
|
50
|
+
else:
|
|
51
|
+
payload = inspect_artifact(document)
|
|
52
|
+
out.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
53
|
+
return 0
|
|
54
|
+
except SpecProblem as exc:
|
|
55
|
+
err.write(json.dumps(exc.as_dict(), indent=2, sort_keys=True) + "\n")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="specmint")
|
|
61
|
+
parser.add_argument("--version", action="store_true", dest="show_version")
|
|
62
|
+
sub = parser.add_subparsers(dest="command")
|
|
63
|
+
sub.add_parser("version")
|
|
64
|
+
mint = sub.add_parser("mint")
|
|
65
|
+
mint.add_argument("mint_args", nargs=argparse.REMAINDER)
|
|
66
|
+
for name in ("validate", "compile", "inspect"):
|
|
67
|
+
cmd = sub.add_parser(name)
|
|
68
|
+
cmd.add_argument("path", nargs="?", default="-")
|
|
69
|
+
cmd.add_argument("--format", choices=("json", "yaml", "markdown", "mint"))
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_document(path: str, *, stdin: IO[bytes] | None, format: str | None) -> dict[str, Any]:
|
|
74
|
+
if path == "-":
|
|
75
|
+
raw = b"" if stdin is None else stdin.read()
|
|
76
|
+
media = media_type_for_format(format) if format else "application/json"
|
|
77
|
+
body = raw if isinstance(raw, bytes | bytearray) else str(raw).encode()
|
|
78
|
+
return decode_body(body, media)
|
|
79
|
+
return decode_path(Path(path), format=format)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Host identity compile for the public language extract. CUE is not included."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opsdevcode_specmint.automation import is_automation_document
|
|
9
|
+
from opsdevcode_specmint.contracts import reject_unsupported_spec
|
|
10
|
+
from opsdevcode_specmint.errors import DocumentParseError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compile_specification(document: dict[str, Any]) -> dict[str, Any]:
|
|
14
|
+
reject_unsupported_spec(document)
|
|
15
|
+
if not is_automation_document(document) and document.get("kind") != "DeliverySpecification":
|
|
16
|
+
raise DocumentParseError("unsupported document kind for language extract compile")
|
|
17
|
+
closed = json.loads(json.dumps(document, sort_keys=True))
|
|
18
|
+
if not isinstance(closed, dict):
|
|
19
|
+
raise DocumentParseError("compile produced a non-object document")
|
|
20
|
+
return closed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_specification(document: dict[str, Any]) -> dict[str, Any]:
|
|
24
|
+
compiled = compile_specification(document)
|
|
25
|
+
return {
|
|
26
|
+
"valid": True,
|
|
27
|
+
"kind": compiled.get("kind"),
|
|
28
|
+
"api_version": compiled.get("apiVersion"),
|
|
29
|
+
"id": compiled.get("metadata", {}).get("id")
|
|
30
|
+
if isinstance(compiled.get("metadata"), dict)
|
|
31
|
+
else None,
|
|
32
|
+
"issues": [],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inspect_artifact(document: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
compiled = compile_specification(document)
|
|
38
|
+
return {"artifact": compiled}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Versioned spec and compiled-intent contracts. CUE stays adapter-private."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opsdevcode_specmint.errors import CompilationFailureError, SpecUnsupportedError
|
|
9
|
+
from opsdevcode_specmint.identity import revision_digest
|
|
10
|
+
from opsdevcode_specmint.pins import (
|
|
11
|
+
AUTOMATION_API_VERSION,
|
|
12
|
+
AUTOMATION_SPEC_KIND,
|
|
13
|
+
COMPILED_INTENT_API_VERSION,
|
|
14
|
+
COMPILED_INTENT_KIND,
|
|
15
|
+
SPEC_API_VERSION,
|
|
16
|
+
SPEC_KIND,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class SpecContract:
|
|
22
|
+
api_version: str
|
|
23
|
+
kind: str
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, str]:
|
|
26
|
+
return {"apiVersion": self.api_version, "kind": self.kind}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DELIVERY_SPEC_V1ALPHA1 = SpecContract(SPEC_API_VERSION, SPEC_KIND)
|
|
30
|
+
COMPILED_INTENT_V1ALPHA1 = SpecContract(COMPILED_INTENT_API_VERSION, COMPILED_INTENT_KIND)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reject_unsupported_spec(document: dict[str, Any]) -> None:
|
|
34
|
+
api_version = document.get("apiVersion")
|
|
35
|
+
kind = document.get("kind")
|
|
36
|
+
delivery = api_version == SPEC_API_VERSION and kind == SPEC_KIND
|
|
37
|
+
automation = api_version == AUTOMATION_API_VERSION and kind == AUTOMATION_SPEC_KIND
|
|
38
|
+
if delivery or automation:
|
|
39
|
+
return
|
|
40
|
+
raise SpecUnsupportedError(
|
|
41
|
+
"Only DeliverySpecification specs.opsdevcode.io/v1alpha1 or "
|
|
42
|
+
"AutomationSpecification automations.opsdevcode.io/v1alpha1 is accepted."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class CompiledIntent:
|
|
48
|
+
identity_id: str
|
|
49
|
+
revision: str
|
|
50
|
+
owner: str
|
|
51
|
+
statement: str
|
|
52
|
+
target_repository: str
|
|
53
|
+
environment_name: str | None
|
|
54
|
+
required_capabilities: tuple[str, ...]
|
|
55
|
+
recorded_baseline_approved: bool
|
|
56
|
+
required_evidence: tuple[str, ...]
|
|
57
|
+
allow_unexpired: bool
|
|
58
|
+
compare: str
|
|
59
|
+
unknown_is_not_compliant: bool
|
|
60
|
+
status: str
|
|
61
|
+
|
|
62
|
+
def to_canonical_dict(self) -> dict[str, Any]:
|
|
63
|
+
intent: dict[str, Any] = {
|
|
64
|
+
"owner": self.owner,
|
|
65
|
+
"statement": self.statement,
|
|
66
|
+
"target": {"repository": self.target_repository},
|
|
67
|
+
"required_capabilities": list(self.required_capabilities),
|
|
68
|
+
"constraints": {"recorded_baseline_approved": self.recorded_baseline_approved},
|
|
69
|
+
"required_evidence": list(self.required_evidence),
|
|
70
|
+
"exception_rules": {"allow_unexpired": self.allow_unexpired},
|
|
71
|
+
"convergence": {
|
|
72
|
+
"compare": self.compare,
|
|
73
|
+
"unknown_is_not_compliant": self.unknown_is_not_compliant,
|
|
74
|
+
},
|
|
75
|
+
"status": self.status,
|
|
76
|
+
}
|
|
77
|
+
if self.environment_name is not None:
|
|
78
|
+
intent["environment"] = {"name": self.environment_name}
|
|
79
|
+
return {
|
|
80
|
+
"apiVersion": COMPILED_INTENT_API_VERSION,
|
|
81
|
+
"kind": COMPILED_INTENT_KIND,
|
|
82
|
+
"identity": {"id": self.identity_id, "revision": self.revision},
|
|
83
|
+
"source": {
|
|
84
|
+
"apiVersion": SPEC_API_VERSION,
|
|
85
|
+
"kind": SPEC_KIND,
|
|
86
|
+
"id": self.identity_id,
|
|
87
|
+
},
|
|
88
|
+
"intent": intent,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_compiled_intent(closed: dict[str, Any]) -> CompiledIntent:
|
|
93
|
+
projected = _project_closed_spec(closed)
|
|
94
|
+
unsigned = replace(projected, revision="")
|
|
95
|
+
body = unsigned.to_canonical_dict()
|
|
96
|
+
del body["identity"]["revision"]
|
|
97
|
+
return replace(projected, revision=revision_digest(body))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _project_closed_spec(closed: dict[str, Any]) -> CompiledIntent:
|
|
101
|
+
spec = closed.get("spec")
|
|
102
|
+
metadata = closed.get("metadata")
|
|
103
|
+
if not isinstance(spec, dict) or not isinstance(metadata, dict):
|
|
104
|
+
raise CompilationFailureError("Closed specification is missing metadata or spec.")
|
|
105
|
+
spec_id = metadata.get("id")
|
|
106
|
+
owner = spec.get("owner")
|
|
107
|
+
statement = spec.get("intent")
|
|
108
|
+
target = spec.get("target")
|
|
109
|
+
constraints = spec.get("constraints")
|
|
110
|
+
exception_rules = spec.get("exception_rules")
|
|
111
|
+
convergence = spec.get("convergence")
|
|
112
|
+
capabilities = spec.get("required_capabilities")
|
|
113
|
+
evidence = spec.get("required_evidence")
|
|
114
|
+
status = spec.get("status")
|
|
115
|
+
if not isinstance(target, dict) or not isinstance(constraints, dict):
|
|
116
|
+
raise CompilationFailureError("Closed specification is missing target or constraints.")
|
|
117
|
+
if not isinstance(exception_rules, dict) or not isinstance(convergence, dict):
|
|
118
|
+
raise CompilationFailureError("Closed specification is missing compiled defaults.")
|
|
119
|
+
if not isinstance(spec_id, str) or not spec_id:
|
|
120
|
+
raise CompilationFailureError("Closed specification is missing metadata.id.")
|
|
121
|
+
if not isinstance(owner, str) or not isinstance(statement, str):
|
|
122
|
+
raise CompilationFailureError("Closed specification is missing owner or intent.")
|
|
123
|
+
repository = target.get("repository")
|
|
124
|
+
approved = constraints.get("recorded_baseline_approved")
|
|
125
|
+
allow_unexpired = exception_rules.get("allow_unexpired")
|
|
126
|
+
compare = convergence.get("compare")
|
|
127
|
+
unknown = convergence.get("unknown_is_not_compliant")
|
|
128
|
+
if not isinstance(repository, str) or not isinstance(approved, bool):
|
|
129
|
+
raise CompilationFailureError("Closed specification target or constraints are incomplete.")
|
|
130
|
+
if not isinstance(allow_unexpired, bool) or not isinstance(unknown, bool):
|
|
131
|
+
raise CompilationFailureError("Closed specification defaults are incomplete.")
|
|
132
|
+
if not isinstance(compare, str) or not isinstance(status, str):
|
|
133
|
+
raise CompilationFailureError("Closed specification convergence or status is incomplete.")
|
|
134
|
+
if not isinstance(capabilities, list) or not all(
|
|
135
|
+
isinstance(item, str) for item in capabilities
|
|
136
|
+
):
|
|
137
|
+
raise CompilationFailureError("Closed specification required_capabilities is incomplete.")
|
|
138
|
+
if not isinstance(evidence, list) or not all(isinstance(item, str) for item in evidence):
|
|
139
|
+
raise CompilationFailureError("Closed specification required_evidence is incomplete.")
|
|
140
|
+
environment = spec.get("environment")
|
|
141
|
+
environment_name: str | None = None
|
|
142
|
+
if environment is not None:
|
|
143
|
+
if not isinstance(environment, dict) or not isinstance(environment.get("name"), str):
|
|
144
|
+
raise CompilationFailureError("Closed specification environment is incomplete.")
|
|
145
|
+
environment_name = environment["name"]
|
|
146
|
+
return CompiledIntent(
|
|
147
|
+
identity_id=spec_id,
|
|
148
|
+
revision="",
|
|
149
|
+
owner=owner,
|
|
150
|
+
statement=statement,
|
|
151
|
+
target_repository=repository,
|
|
152
|
+
environment_name=environment_name,
|
|
153
|
+
required_capabilities=tuple(capabilities),
|
|
154
|
+
recorded_baseline_approved=approved,
|
|
155
|
+
required_evidence=tuple(evidence),
|
|
156
|
+
allow_unexpired=allow_unexpired,
|
|
157
|
+
compare=compare,
|
|
158
|
+
unknown_is_not_compliant=unknown,
|
|
159
|
+
status=status,
|
|
160
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""HTTP problem payloads. Operational codes, not policy decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SpecProblem(Exception):
|
|
10
|
+
status: int
|
|
11
|
+
code: str
|
|
12
|
+
title: str
|
|
13
|
+
detail: str
|
|
14
|
+
|
|
15
|
+
def as_dict(self) -> dict[str, object]:
|
|
16
|
+
return {
|
|
17
|
+
"type": f"about:specmint/{self.code.lower()}",
|
|
18
|
+
"title": self.title,
|
|
19
|
+
"status": self.status,
|
|
20
|
+
"detail": self.detail,
|
|
21
|
+
"code": self.code,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DocumentParseError(SpecProblem):
|
|
26
|
+
def __init__(self, detail: str) -> None:
|
|
27
|
+
super().__init__(400, "DOCUMENT_PARSE_FAILED", "Document parse failed", detail)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedMediaError(SpecProblem):
|
|
31
|
+
def __init__(self, detail: str) -> None:
|
|
32
|
+
super().__init__(415, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type", detail)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DocumentTooLargeError(SpecProblem):
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
413,
|
|
39
|
+
"DOCUMENT_TOO_LARGE",
|
|
40
|
+
"Document too large",
|
|
41
|
+
"Specification documents must be 65536 bytes or smaller.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CueUnavailableError(SpecProblem):
|
|
46
|
+
def __init__(self, detail: str) -> None:
|
|
47
|
+
super().__init__(503, "CUE_UNAVAILABLE", "CUE engine unavailable", detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SpecInvalidError(SpecProblem):
|
|
51
|
+
def __init__(self, detail: str) -> None:
|
|
52
|
+
super().__init__(422, "SPEC_INVALID", "Specification invalid", detail)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SpecUnsupportedError(SpecProblem):
|
|
56
|
+
def __init__(self, detail: str) -> None:
|
|
57
|
+
super().__init__(422, "SPEC_UNSUPPORTED", "Specification unsupported", detail)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CompilationFailureError(SpecProblem):
|
|
61
|
+
def __init__(self, detail: str) -> None:
|
|
62
|
+
super().__init__(422, "COMPILATION_FAILURE", "Compilation failed", detail)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Specification revision identity. Independent of policy artifact identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def revision_digest(compiled: dict[str, Any]) -> str:
|
|
11
|
+
canonical = json.dumps(compiled, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
12
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
13
|
+
return f"sha256:{digest}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public Mint Integration SDK. No compiler, network, or provider SDK imports."""
|
|
2
|
+
|
|
3
|
+
from opsdevcode_specmint.integration.canonical import SCHEMA_INTEGRATION, SCHEMA_PROTOCOL
|
|
4
|
+
from opsdevcode_specmint.integration.models import IntegrationManifest, parse_manifest
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SCHEMA_INTEGRATION",
|
|
8
|
+
"SCHEMA_PROTOCOL",
|
|
9
|
+
"IntegrationManifest",
|
|
10
|
+
"parse_manifest",
|
|
11
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Deterministic JSON and content digests. Stdlib only; no compiler imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SCHEMA_INTEGRATION = "mint.integration/v0"
|
|
10
|
+
SCHEMA_PROTOCOL = "mint.protocol/v0"
|
|
11
|
+
SCHEMA_REQUEST = "mint.protocol.request/v0"
|
|
12
|
+
SCHEMA_RESULT = "mint.protocol.result/v0"
|
|
13
|
+
SCHEMA_REALIZATION = "mint.realization/v0"
|
|
14
|
+
SCHEMA_OBSERVATION = "mint.observation/v0"
|
|
15
|
+
SCHEMA_OPERATION = "mint.operation/v0"
|
|
16
|
+
SCHEMA_VERIFICATION = "mint.verification/v0"
|
|
17
|
+
SCHEMA_EVIDENCE = "mint.evidence/v0"
|
|
18
|
+
PROTOCOL_VERSION = SCHEMA_PROTOCOL
|
|
19
|
+
|
|
20
|
+
PHASES = (
|
|
21
|
+
"describe",
|
|
22
|
+
"validate",
|
|
23
|
+
"observe",
|
|
24
|
+
"plan",
|
|
25
|
+
"verify",
|
|
26
|
+
"recover",
|
|
27
|
+
"evidence",
|
|
28
|
+
"execute",
|
|
29
|
+
)
|
|
30
|
+
NONPRIVILEGED_PHASES = (
|
|
31
|
+
"describe",
|
|
32
|
+
"validate",
|
|
33
|
+
"observe",
|
|
34
|
+
"plan",
|
|
35
|
+
"verify",
|
|
36
|
+
"recover",
|
|
37
|
+
"evidence",
|
|
38
|
+
)
|
|
39
|
+
EXECUTION_SUPPORT = ("none", "fake", "privileged")
|
|
40
|
+
SECRET_KEYS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"access_key",
|
|
43
|
+
"api_key",
|
|
44
|
+
"authorization",
|
|
45
|
+
"credential",
|
|
46
|
+
"password",
|
|
47
|
+
"pat",
|
|
48
|
+
"private_key",
|
|
49
|
+
"secret",
|
|
50
|
+
"token",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonical_json_bytes(mapping: Any) -> bytes:
|
|
56
|
+
payload = json.dumps(mapping, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
57
|
+
return f"{payload}\n".encode()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def digest_bytes(payload: bytes) -> str:
|
|
61
|
+
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def content_digest(mapping: Any) -> str:
|
|
65
|
+
return digest_bytes(canonical_json_bytes(mapping))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reject_unknown(document: dict[str, Any], allowed: frozenset[str], *, label: str) -> None:
|
|
69
|
+
unknown = sorted(set(document) - allowed)
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ValueError(f"{label} contains unknown fields {unknown}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reject_secrets(value: Any, *, label: str) -> None:
|
|
75
|
+
if isinstance(value, dict):
|
|
76
|
+
for key, item in value.items():
|
|
77
|
+
if str(key).lower() in SECRET_KEYS:
|
|
78
|
+
raise ValueError(f"{label} contains a secret-shaped field {key}")
|
|
79
|
+
reject_secrets(item, label=label)
|
|
80
|
+
elif isinstance(value, list):
|
|
81
|
+
for item in value:
|
|
82
|
+
reject_secrets(item, label=label)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def reject_absolute_path(text: str, *, label: str) -> None:
|
|
86
|
+
if text.startswith("/") or (len(text) > 2 and text[1] == ":"):
|
|
87
|
+
raise ValueError(f"{label} contains an absolute path")
|