manifest.py python
378 lines 14.0 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 22 hours ago
1 """Load and validate ``.overseer/workspace.yaml`` (§MR.4)."""
2
3 from __future__ import annotations
4
5 import os
6 import re
7 from pathlib import Path
8 from typing import Any
9
10 import yaml
11
12 from adapters.config import OverseerConfig, load_config
13 from adapters.errors import ConfigError
14 from tools.workspace.types import (
15 FORBIDDEN_IDENTITY_KEYS,
16 SUPPORTED_REGIMES,
17 WORKSPACE_ROLES,
18 ManifestSource,
19 WorkspaceLaneConfig,
20 WorkspaceLoadError,
21 WorkspaceManifest,
22 WorkspaceMemberConfig,
23 )
24
25 _ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
26 _SECRETISH = re.compile(
27 r"(?i)(password|secret|token|api[_-]?key|bearer\s+[A-Za-z0-9._\-]+|"
28 r"https?://[^/\s]+:[^@/\s]+@)"
29 )
30
31
32 def expand_root(raw: str, *, environ: dict[str, str] | None = None, home: Path | None = None) -> str:
33 """Expand ``${ENV}`` / ``${ENV:-default}`` and leading ``~`` (§MR.4.3)."""
34 env = environ if environ is not None else dict(os.environ)
35 home_path = home if home is not None else Path.home()
36
37 def _repl(match: re.Match[str]) -> str:
38 key = match.group(1)
39 default = match.group(2)
40 if key in env and env[key] != "":
41 return env[key]
42 if default is not None:
43 return default
44 return ""
45
46 expanded = _ENV_REF.sub(_repl, raw.strip())
47 if expanded.startswith("~"):
48 expanded = str(home_path) + expanded[1:]
49 return expanded
50
51
52 def resolve_member_root(
53 raw: str,
54 *,
55 environ: dict[str, str] | None = None,
56 home: Path | None = None,
57 ) -> Path | None:
58 """Resolve member root to an absolute path, or ``None`` when empty after expand."""
59 text = expand_root(raw, environ=environ, home=home).strip()
60 if not text:
61 return None
62 return Path(text).expanduser().resolve()
63
64
65 def _walk_forbid_identity(node: Any, path: str) -> None:
66 if isinstance(node, dict):
67 for key, value in node.items():
68 key_l = str(key).strip().lower().replace("-", "_")
69 if key_l in FORBIDDEN_IDENTITY_KEYS or "secret" in key_l or "password" in key_l:
70 raise WorkspaceLoadError(
71 f"forbidden identity/secret key in manifest: {key}",
72 citation=path,
73 )
74 if isinstance(value, str) and _SECRETISH.search(value):
75 raise WorkspaceLoadError(
76 f"secret-shaped value rejected at {key}",
77 citation=path,
78 )
79 _walk_forbid_identity(value, path)
80 elif isinstance(node, list):
81 for item in node:
82 _walk_forbid_identity(item, path)
83 elif isinstance(node, str) and _SECRETISH.search(node):
84 raise WorkspaceLoadError("secret-shaped string rejected in manifest", citation=path)
85
86
87 def validate_manifest_dict(
88 raw: dict[str, Any],
89 *,
90 source_path: Path,
91 manifest_source: ManifestSource,
92 expected_constellation_id: str | None = None,
93 ) -> WorkspaceManifest:
94 """Validate workspace.yaml mapping; fail closed on schema violations."""
95 path = str(source_path)
96 _walk_forbid_identity(raw, path)
97
98 version = raw.get("overseer_workspace_version")
99 if version != 1:
100 raise WorkspaceLoadError(
101 f"unsupported overseer_workspace_version {version!r} (supported: 1)",
102 citation=path,
103 )
104
105 constellation_id = raw.get("id")
106 if not isinstance(constellation_id, str) or not constellation_id.strip():
107 raise WorkspaceLoadError("workspace id must be a non-empty string", citation=path)
108 constellation_id = constellation_id.strip()
109 if expected_constellation_id and constellation_id != expected_constellation_id:
110 raise WorkspaceLoadError(
111 f"manifest id {constellation_id!r} != constellation_id "
112 f"{expected_constellation_id!r}",
113 citation=path,
114 )
115
116 product_order_member = raw.get("product_order_member")
117 if not isinstance(product_order_member, str) or not product_order_member.strip():
118 raise WorkspaceLoadError("product_order_member must be a non-empty string", citation=path)
119 product_order_member = product_order_member.strip()
120
121 strict_markers = raw.get("strict_markers", True)
122 if not isinstance(strict_markers, bool):
123 raise WorkspaceLoadError("strict_markers must be a boolean", citation=path)
124
125 strict_board_names = raw.get("strict_board_names", True)
126 if not isinstance(strict_board_names, bool):
127 raise WorkspaceLoadError("strict_board_names must be a boolean", citation=path)
128
129 members_raw = raw.get("members")
130 if not isinstance(members_raw, list) or not members_raw:
131 raise WorkspaceLoadError("members must be a non-empty list", citation=path)
132
133 members: list[WorkspaceMemberConfig] = []
134 seen_ids: set[str] = set()
135 product_order_count = 0
136 for idx, row in enumerate(members_raw):
137 if not isinstance(row, dict):
138 raise WorkspaceLoadError(f"members[{idx}] must be a mapping", citation=path)
139 mid = row.get("id")
140 if not isinstance(mid, str) or not mid.strip():
141 raise WorkspaceLoadError(f"members[{idx}].id must be a non-empty string", citation=path)
142 mid = mid.strip()
143 if mid in seen_ids:
144 raise WorkspaceLoadError(f"duplicate member id {mid!r}", citation=path)
145 seen_ids.add(mid)
146
147 role = row.get("role")
148 if role not in WORKSPACE_ROLES:
149 raise WorkspaceLoadError(
150 f"members[{idx}].role must be one of {sorted(WORKSPACE_ROLES)}",
151 citation=path,
152 )
153 if role == "product_order":
154 product_order_count += 1
155
156 root_raw = row.get("root")
157 if not isinstance(root_raw, str):
158 raise WorkspaceLoadError(f"members[{idx}].root must be a string", citation=path)
159
160 required = row.get("required", True)
161 if not isinstance(required, bool):
162 raise WorkspaceLoadError(f"members[{idx}].required must be a boolean", citation=path)
163
164 relay = row.get("relay", False)
165 if not isinstance(relay, bool):
166 raise WorkspaceLoadError(f"members[{idx}].relay must be a boolean", citation=path)
167 if role == "product_order" and relay:
168 raise WorkspaceLoadError("product_order member must have relay: false", citation=path)
169
170 regime = row.get("regime", None)
171 if regime is not None and regime not in SUPPORTED_REGIMES:
172 raise WorkspaceLoadError(
173 f"members[{idx}].regime must be muse+git-mirror|muse-only|git-only|null",
174 citation=path,
175 )
176 if regime is None and required:
177 raise WorkspaceLoadError(
178 f"members[{idx}].regime null only allowed when required: false",
179 citation=path,
180 )
181
182 handover = row.get("handover")
183 roadmap = row.get("roadmap")
184 if handover is not None and not isinstance(handover, str):
185 raise WorkspaceLoadError(f"members[{idx}].handover must be string or null", citation=path)
186 if roadmap is not None and not isinstance(roadmap, str):
187 raise WorkspaceLoadError(f"members[{idx}].roadmap must be string or null", citation=path)
188
189 relay_lanes_raw = row.get("relay_lanes", [])
190 if relay_lanes_raw is None:
191 relay_lanes_raw = []
192 if not isinstance(relay_lanes_raw, list) or not all(
193 isinstance(x, str) for x in relay_lanes_raw
194 ):
195 raise WorkspaceLoadError(
196 f"members[{idx}].relay_lanes must be a list of strings",
197 citation=path,
198 )
199
200 members.append(
201 WorkspaceMemberConfig(
202 id=mid,
203 role=str(role),
204 root_raw=root_raw,
205 regime=regime,
206 required=required,
207 relay=relay,
208 handover=handover,
209 roadmap=roadmap,
210 relay_lanes=tuple(relay_lanes_raw),
211 )
212 )
213
214 if product_order_count != 1:
215 raise WorkspaceLoadError(
216 f"exactly one product_order member required (got {product_order_count})",
217 citation=path,
218 )
219 po = next(m for m in members if m.role == "product_order")
220 if po.id != product_order_member:
221 raise WorkspaceLoadError(
222 f"product_order_member {product_order_member!r} does not match "
223 f"product_order member id {po.id!r}",
224 citation=path,
225 )
226
227 lanes_raw = raw.get("lanes")
228 if not isinstance(lanes_raw, list) or not lanes_raw:
229 raise WorkspaceLoadError("lanes must be a non-empty list", citation=path)
230
231 lanes: list[WorkspaceLaneConfig] = []
232 primary_count = 0
233 lane_ids: set[str] = set()
234 for idx, row in enumerate(lanes_raw):
235 if not isinstance(row, dict):
236 raise WorkspaceLoadError(f"lanes[{idx}] must be a mapping", citation=path)
237 lid = row.get("id")
238 if not isinstance(lid, str) or not lid.strip():
239 raise WorkspaceLoadError(f"lanes[{idx}].id must be a non-empty string", citation=path)
240 lid = lid.strip()
241 if lid in lane_ids:
242 raise WorkspaceLoadError(f"duplicate lane id {lid!r}", citation=path)
243 lane_ids.add(lid)
244 primary = row.get("primary", False)
245 if not isinstance(primary, bool):
246 raise WorkspaceLoadError(f"lanes[{idx}].primary must be a boolean", citation=path)
247 if primary:
248 primary_count += 1
249 owner = row.get("owner_member")
250 if owner is not None and (not isinstance(owner, str) or not owner.strip()):
251 raise WorkspaceLoadError(
252 f"lanes[{idx}].owner_member must be a string or null",
253 citation=path,
254 )
255 if isinstance(owner, str):
256 owner = owner.strip()
257 if owner not in seen_ids:
258 raise WorkspaceLoadError(
259 f"lanes[{idx}].owner_member {owner!r} is not a member id",
260 citation=path,
261 )
262 lanes.append(
263 WorkspaceLaneConfig(
264 id=lid,
265 primary=primary,
266 owner_member=owner if isinstance(owner, str) else None,
267 )
268 )
269
270 if primary_count != 1:
271 raise WorkspaceLoadError(
272 f"exactly one primary lane required (got {primary_count})",
273 citation=path,
274 )
275
276 return WorkspaceManifest(
277 version=1,
278 id=constellation_id,
279 product_order_member=product_order_member,
280 strict_markers=strict_markers,
281 strict_board_names=strict_board_names,
282 members=tuple(members),
283 lanes=tuple(lanes),
284 source_path=source_path.resolve(),
285 manifest_source=manifest_source,
286 )
287
288
289 def load_manifest_file(
290 path: Path,
291 *,
292 manifest_source: ManifestSource,
293 expected_constellation_id: str | None = None,
294 ) -> WorkspaceManifest:
295 """Read and validate a workspace.yaml file."""
296 if not path.is_file():
297 raise WorkspaceLoadError(f"workspace manifest missing: {path}", citation=str(path))
298 try:
299 raw = yaml.safe_load(path.read_text(encoding="utf-8"))
300 except (OSError, yaml.YAMLError) as exc:
301 raise WorkspaceLoadError(f"unparseable workspace manifest: {exc}", citation=str(path)) from exc
302 if not isinstance(raw, dict):
303 raise WorkspaceLoadError("workspace manifest root must be a mapping", citation=str(path))
304 return validate_manifest_dict(
305 raw,
306 source_path=path,
307 manifest_source=manifest_source,
308 expected_constellation_id=expected_constellation_id,
309 )
310
311
312 def discover_manifest(
313 config: OverseerConfig,
314 repo_root: Path,
315 *,
316 environ: dict[str, str] | None = None,
317 home: Path | None = None,
318 ) -> WorkspaceManifest | None:
319 """Discover workspace manifest from member config pointer (§MR.4.2).
320
321 Returns ``None`` when ``workspace:`` is absent (single-repo only).
322 """
323 ws = config.workspace
324 if ws is None:
325 return None
326
327 env = environ if environ is not None else dict(os.environ)
328 home_path = home if home is not None else Path.home()
329 expected_id = ws.constellation_id
330
331 # §MR.3.3 — env override replaces product_order path when set (checked early
332 # so fixtures/CI can force a reviewed remapped file). Still validate id.
333 env_manifest = env.get("OVERSEER_WORKSPACE_MANIFEST", "").strip()
334
335 candidates: list[tuple[Path, ManifestSource]] = []
336 if ws.manifest:
337 candidates.append((Path(expand_root(ws.manifest, environ=env, home=home_path)), "config_manifest"))
338 if ws.product_order_root:
339 po_root = Path(expand_root(ws.product_order_root, environ=env, home=home_path))
340 candidates.append((po_root / ".overseer" / "workspace.yaml", "product_order_root"))
341 local = repo_root / ".overseer" / "workspace.yaml"
342 if local.is_file():
343 candidates.append((local, "local_workspace"))
344 if env_manifest:
345 candidates.append((Path(expand_root(env_manifest, environ=env, home=home_path)), "env_override"))
346 home_idx = home_path / ".overseer" / "workspaces" / f"{expected_id}.yaml"
347 candidates.append((home_idx, "home_index"))
348
349 # Prefer env_override when set even if earlier candidates exist (§MR.3.3).
350 if env_manifest:
351 path = Path(expand_root(env_manifest, environ=env, home=home_path))
352 return load_manifest_file(
353 path,
354 manifest_source="env_override",
355 expected_constellation_id=expected_id,
356 )
357
358 for path, source in candidates:
359 if path.is_file():
360 return load_manifest_file(
361 path,
362 manifest_source=source,
363 expected_constellation_id=expected_id,
364 )
365
366 raise WorkspaceLoadError(
367 f"workspace configured (constellation_id={expected_id!r}) but no manifest found",
368 citation=str(repo_root / ".overseer" / "config.yaml"),
369 )
370
371
372 def load_member_config(root: Path) -> OverseerConfig:
373 """Load a peer member's ``.overseer/config.yaml``."""
374 cfg = root / ".overseer" / "config.yaml"
375 try:
376 return load_config(cfg)
377 except ConfigError as exc:
378 raise WorkspaceLoadError(str(exc), citation=str(cfg)) from exc
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 22 hours ago