Skip to content

Commit 08891d8

Browse files
committed
Add YAML deployment validation script
1 parent a6fae79 commit 08891d8

1 file changed

Lines changed: 155 additions & 0 deletions

File tree

scripts/validate-yaml.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
"""Validate Agent Machine YAML deployment manifests.
3+
4+
The current YAML lane is intentionally narrow. We parse every YAML document under
5+
deploy/ and apply structural safety checks that are useful before we add a full
6+
Kubernetes policy engine or kubeconform lane.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from pathlib import Path
13+
from typing import Any
14+
15+
try:
16+
import yaml
17+
except ImportError as exc: # pragma: no cover - exercised in environments without deps
18+
raise SystemExit(
19+
"Missing dependency: PyYAML. Install with `python -m pip install -r requirements-dev.txt`."
20+
) from exc
21+
22+
REPO_ROOT = Path(__file__).resolve().parents[1]
23+
DEPLOY_DIR = REPO_ROOT / "deploy"
24+
25+
K8S_REQUIRED_TOP_LEVEL = {"apiVersion", "kind", "metadata"}
26+
27+
28+
def iter_yaml_files(directory: Path) -> list[Path]:
29+
if not directory.exists():
30+
return []
31+
return sorted(
32+
path
33+
for path in directory.rglob("*")
34+
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
35+
)
36+
37+
38+
def load_yaml_documents(path: Path) -> list[Any]:
39+
try:
40+
with path.open("r", encoding="utf-8") as handle:
41+
docs = list(yaml.safe_load_all(handle))
42+
except yaml.YAMLError as exc:
43+
raise AssertionError(f"{path}: invalid YAML: {exc}") from exc
44+
return [doc for doc in docs if doc is not None]
45+
46+
47+
def require_mapping(path: Path, index: int, doc: Any) -> dict[str, Any]:
48+
if not isinstance(doc, dict):
49+
raise AssertionError(f"{path}: document {index}: expected mapping/object root")
50+
return doc
51+
52+
53+
def validate_k8s_doc(path: Path, index: int, doc: dict[str, Any]) -> None:
54+
missing = sorted(K8S_REQUIRED_TOP_LEVEL - set(doc))
55+
if missing:
56+
raise AssertionError(f"{path}: document {index}: missing top-level keys: {', '.join(missing)}")
57+
58+
metadata = doc.get("metadata")
59+
if not isinstance(metadata, dict):
60+
raise AssertionError(f"{path}: document {index}: metadata must be an object")
61+
name = metadata.get("name")
62+
if not isinstance(name, str) or not name:
63+
raise AssertionError(f"{path}: document {index}: metadata.name is required")
64+
65+
kind = doc.get("kind")
66+
if kind == "Pod":
67+
validate_pod(path, index, doc)
68+
if kind == "Service":
69+
validate_service(path, index, doc)
70+
if kind == "PersistentVolumeClaim":
71+
validate_pvc(path, index, doc)
72+
if kind == "NetworkPolicy":
73+
validate_network_policy(path, index, doc)
74+
75+
76+
def validate_pod(path: Path, index: int, doc: dict[str, Any]) -> None:
77+
spec = doc.get("spec")
78+
if not isinstance(spec, dict):
79+
raise AssertionError(f"{path}: document {index}: Pod spec must be an object")
80+
containers = spec.get("containers")
81+
if not isinstance(containers, list) or not containers:
82+
raise AssertionError(f"{path}: document {index}: Pod spec.containers must be a non-empty list")
83+
for container_index, container in enumerate(containers):
84+
if not isinstance(container, dict):
85+
raise AssertionError(f"{path}: document {index}: container {container_index} must be an object")
86+
image = container.get("image")
87+
if not isinstance(image, str) or not image:
88+
raise AssertionError(f"{path}: document {index}: container {container_index} image is required")
89+
security_context = container.get("securityContext")
90+
if not isinstance(security_context, dict):
91+
raise AssertionError(f"{path}: document {index}: container {container_index} securityContext is required")
92+
if security_context.get("allowPrivilegeEscalation") is not False:
93+
raise AssertionError(
94+
f"{path}: document {index}: container {container_index} must set allowPrivilegeEscalation: false"
95+
)
96+
if security_context.get("readOnlyRootFilesystem") is not True:
97+
raise AssertionError(
98+
f"{path}: document {index}: container {container_index} must set readOnlyRootFilesystem: true"
99+
)
100+
101+
102+
def validate_service(path: Path, index: int, doc: dict[str, Any]) -> None:
103+
spec = doc.get("spec")
104+
if not isinstance(spec, dict):
105+
raise AssertionError(f"{path}: document {index}: Service spec must be an object")
106+
if spec.get("type") not in {None, "ClusterIP"}:
107+
raise AssertionError(f"{path}: document {index}: Service type must stay ClusterIP for skeleton manifests")
108+
109+
110+
def validate_pvc(path: Path, index: int, doc: dict[str, Any]) -> None:
111+
spec = doc.get("spec")
112+
if not isinstance(spec, dict):
113+
raise AssertionError(f"{path}: document {index}: PVC spec must be an object")
114+
if not spec.get("storageClassName"):
115+
raise AssertionError(f"{path}: document {index}: PVC storageClassName is required")
116+
resources = spec.get("resources")
117+
if not isinstance(resources, dict) or not isinstance(resources.get("requests"), dict):
118+
raise AssertionError(f"{path}: document {index}: PVC resources.requests is required")
119+
if not resources["requests"].get("storage"):
120+
raise AssertionError(f"{path}: document {index}: PVC resources.requests.storage is required")
121+
122+
123+
def validate_network_policy(path: Path, index: int, doc: dict[str, Any]) -> None:
124+
spec = doc.get("spec")
125+
if not isinstance(spec, dict):
126+
raise AssertionError(f"{path}: document {index}: NetworkPolicy spec must be an object")
127+
policy_types = spec.get("policyTypes")
128+
if not isinstance(policy_types, list) or not policy_types:
129+
raise AssertionError(f"{path}: document {index}: NetworkPolicy policyTypes must be non-empty")
130+
131+
132+
def main() -> int:
133+
yaml_files = iter_yaml_files(DEPLOY_DIR)
134+
if not yaml_files:
135+
print("No YAML files found under deploy/")
136+
return 0
137+
138+
for path in yaml_files:
139+
docs = load_yaml_documents(path)
140+
if not docs:
141+
raise AssertionError(f"{path}: no YAML documents found")
142+
for index, raw_doc in enumerate(docs, start=1):
143+
doc = require_mapping(path, index, raw_doc)
144+
# For now every YAML file under deploy/ is treated as Kubernetes YAML.
145+
validate_k8s_doc(path, index, doc)
146+
print(f"VALID yaml {path.relative_to(REPO_ROOT)} ({len(docs)} document(s))")
147+
return 0
148+
149+
150+
if __name__ == "__main__":
151+
try:
152+
raise SystemExit(main())
153+
except AssertionError as exc:
154+
print(str(exc), file=sys.stderr)
155+
raise SystemExit(1) from exc

0 commit comments

Comments
 (0)