generated from coulomb/repo-seed
Add multi-repo registry onboarding
This commit is contained in:
@@ -10,7 +10,7 @@ import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .loader import load_yaml
|
||||
from .loader import declaration_files, load_yaml
|
||||
from .graph import FabricGraph, build_graph
|
||||
from .validation import validate_roots
|
||||
|
||||
@@ -75,6 +75,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sync.add_argument("--commit", default=None)
|
||||
sync.add_argument("--json", action="store_true", help="Print the raw snapshot response.")
|
||||
|
||||
sync_manifest = registry_sub.add_parser("sync-manifest", help="Register and sync repos from an onboarding manifest.")
|
||||
sync_manifest.add_argument("manifest", type=Path)
|
||||
sync_manifest.add_argument("--registry-url", default=None, help="Override the manifest registry_url.")
|
||||
sync_manifest.add_argument("--strict", action="store_true", help="Exit non-zero when any repo cannot be synced.")
|
||||
sync_manifest.add_argument("--json", action="store_true", help="Print the raw manifest sync summary.")
|
||||
|
||||
cyclonedx = registry_sub.add_parser("ingest-cyclonedx", help="Ingest a CycloneDX SBOM as library inventory.")
|
||||
cyclonedx.add_argument("sbom", type=Path)
|
||||
cyclonedx.add_argument("--registry-url", default="http://127.0.0.1:8765")
|
||||
@@ -131,6 +137,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.command == "registry":
|
||||
if args.registry_command == "sync":
|
||||
return _registry_sync(args)
|
||||
if args.registry_command == "sync-manifest":
|
||||
return _registry_sync_manifest(args)
|
||||
if args.registry_command == "ingest-cyclonedx":
|
||||
return _registry_ingest_cyclonedx(args)
|
||||
|
||||
@@ -177,6 +185,166 @@ def _registry_sync(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _registry_sync_manifest(args: argparse.Namespace) -> int:
|
||||
manifest_path = args.manifest.resolve()
|
||||
manifest = load_yaml(manifest_path)
|
||||
if not isinstance(manifest, dict):
|
||||
print(f"ERROR {manifest_path}: manifest must be a YAML mapping", file=sys.stderr)
|
||||
return 1
|
||||
repositories = manifest.get("repositories")
|
||||
if not isinstance(repositories, list):
|
||||
print(f"ERROR {manifest_path}: manifest requires a repositories list", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
registry_url = args.registry_url or str(manifest.get("registry_url") or "http://127.0.0.1:8765")
|
||||
results = [
|
||||
_sync_manifest_repo(registry_url, manifest_path.parent, item)
|
||||
for item in repositories
|
||||
]
|
||||
summary = {
|
||||
"manifest": str(manifest_path),
|
||||
"registry_url": registry_url,
|
||||
"repositories": results,
|
||||
"counts": {
|
||||
"total": len(results),
|
||||
"synced": sum(1 for item in results if item["status"] == "synced"),
|
||||
"registered": sum(1 for item in results if item["status"] == "registered"),
|
||||
"errors": sum(1 for item in results if item["status"] == "error"),
|
||||
},
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
else:
|
||||
for item in results:
|
||||
if item["status"] == "synced":
|
||||
print(f"synced {item['slug']} snapshot {item['snapshot_id']} ({item['commit']})")
|
||||
elif item["status"] == "registered":
|
||||
print(f"registered {item['slug']} ({'; '.join(item['warnings'])})")
|
||||
else:
|
||||
print(f"error {item['slug']}: {item['error']}")
|
||||
counts = summary["counts"]
|
||||
print(
|
||||
f"summary: {counts['total']} repo(s), {counts['synced']} synced, "
|
||||
f"{counts['registered']} registered only, {counts['errors']} error(s)"
|
||||
)
|
||||
if args.strict and (summary["counts"]["errors"] or summary["counts"]["registered"]):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _sync_manifest_repo(registry_url: str, manifest_dir: Path, item: object) -> dict[str, object]:
|
||||
if not isinstance(item, dict):
|
||||
return {"slug": "<invalid>", "status": "error", "error": "repository entry must be a mapping"}
|
||||
slug = str(item.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"slug": "<missing>", "status": "error", "error": "repository entry requires slug"}
|
||||
|
||||
repo_path = _manifest_optional_path(item.get("path"), manifest_dir)
|
||||
result: dict[str, object] = {"slug": slug, "status": "registered", "warnings": []}
|
||||
try:
|
||||
repository = _registry_post_checked(
|
||||
registry_url,
|
||||
"/repositories",
|
||||
{
|
||||
"slug": slug,
|
||||
"name": item.get("name") or (repo_path.name if repo_path else slug),
|
||||
"remote_url": item.get("remote_url") or _git_value(repo_path, "config", "--get", "remote.origin.url"),
|
||||
"default_branch": item.get("default_branch") or "main",
|
||||
"state_hub_repo_id": item.get("state_hub_repo_id"),
|
||||
},
|
||||
)
|
||||
result["repository"] = repository
|
||||
except RegistryRequestError as exc:
|
||||
return {"slug": slug, "status": "error", "error": str(exc), "warnings": []}
|
||||
|
||||
if repo_path is None:
|
||||
result["warnings"].append("no repo path configured")
|
||||
return result
|
||||
if not repo_path.is_dir():
|
||||
result["warnings"].append(f"repo path not found: {repo_path}")
|
||||
return result
|
||||
|
||||
graph_paths = _manifest_paths(item.get("declaration_paths"), manifest_dir) or [repo_path]
|
||||
if not any(declaration_files(path) for path in graph_paths):
|
||||
result["warnings"].append("no Fabric declarations found")
|
||||
try:
|
||||
_ingest_manifest_sboms(registry_url, manifest_dir, slug, item, result)
|
||||
except RegistryRequestError as exc:
|
||||
return {"slug": slug, "status": "error", "error": str(exc), "warnings": result["warnings"]}
|
||||
return result
|
||||
|
||||
report = validate_roots(graph_paths)
|
||||
if report.errors:
|
||||
return {
|
||||
"slug": slug,
|
||||
"status": "error",
|
||||
"error": report.summary(),
|
||||
"warnings": [diagnostic.format() for diagnostic in report.diagnostics],
|
||||
}
|
||||
|
||||
graph = build_graph(graph_paths)
|
||||
if graph.load_errors:
|
||||
return {
|
||||
"slug": slug,
|
||||
"status": "error",
|
||||
"error": "; ".join(f"{path}: {message}" for path, message in graph.load_errors),
|
||||
"warnings": [],
|
||||
}
|
||||
try:
|
||||
snapshot = _registry_post_checked(
|
||||
registry_url,
|
||||
f"/repositories/{slug}/snapshots",
|
||||
{
|
||||
"commit": item.get("commit") or _git_value(repo_path, "rev-parse", "HEAD") or "working-tree",
|
||||
"generated_at": _utc_now(),
|
||||
"graph": graph.to_export(),
|
||||
},
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"status": "synced",
|
||||
"snapshot_id": snapshot["id"],
|
||||
"commit": snapshot["commit"],
|
||||
}
|
||||
)
|
||||
_ingest_manifest_sboms(registry_url, manifest_dir, slug, item, result)
|
||||
except RegistryRequestError as exc:
|
||||
return {"slug": slug, "status": "error", "error": str(exc), "warnings": result["warnings"]}
|
||||
return result
|
||||
|
||||
|
||||
def _ingest_manifest_sboms(
|
||||
registry_url: str,
|
||||
manifest_dir: Path,
|
||||
slug: str,
|
||||
item: dict[str, object],
|
||||
result: dict[str, object],
|
||||
) -> None:
|
||||
sbom_paths = _manifest_paths(item.get("sboms"), manifest_dir)
|
||||
single_sbom = _manifest_optional_path(item.get("sbom"), manifest_dir)
|
||||
if single_sbom:
|
||||
sbom_paths.insert(0, single_sbom)
|
||||
ingested: list[dict[str, object]] = []
|
||||
for sbom_path in sbom_paths:
|
||||
if not sbom_path.is_file():
|
||||
result.setdefault("warnings", []).append(f"SBOM not found: {sbom_path}")
|
||||
continue
|
||||
payload = load_yaml(sbom_path)
|
||||
if not isinstance(payload, dict):
|
||||
result.setdefault("warnings", []).append(f"SBOM must be an object: {sbom_path}")
|
||||
continue
|
||||
ingested.append(
|
||||
_registry_post_checked(
|
||||
registry_url,
|
||||
f"/repositories/{slug}/libraries/cyclonedx",
|
||||
payload,
|
||||
)
|
||||
)
|
||||
if ingested:
|
||||
result["libraries"] = ingested
|
||||
|
||||
|
||||
def _registry_ingest_cyclonedx(args: argparse.Namespace) -> int:
|
||||
payload = load_yaml(args.sbom)
|
||||
if not isinstance(payload, dict):
|
||||
@@ -194,7 +362,19 @@ def _registry_ingest_cyclonedx(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class RegistryRequestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _registry_post(registry_url: str, path: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
try:
|
||||
return _registry_post_checked(registry_url, path, payload)
|
||||
except RegistryRequestError as exc:
|
||||
print(f"ERROR {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def _registry_post_checked(registry_url: str, path: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
data = json.dumps({key: value for key, value in payload.items() if value is not None}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
registry_url.rstrip("/") + path,
|
||||
@@ -207,17 +387,38 @@ def _registry_post(registry_url: str, path: str, payload: dict[str, object]) ->
|
||||
body = json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"ERROR registry request failed ({exc.code}): {detail}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
raise RegistryRequestError(f"registry request failed ({exc.code}): {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"ERROR cannot reach registry at {registry_url}: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
raise RegistryRequestError(f"cannot reach registry at {registry_url}: {exc}") from exc
|
||||
if not isinstance(body, dict):
|
||||
print("ERROR registry returned a non-object response", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise RegistryRequestError("registry returned a non-object response")
|
||||
return body
|
||||
|
||||
|
||||
def _manifest_optional_path(value: object, manifest_dir: Path) -> Path | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
path = Path(value).expanduser()
|
||||
return path if path.is_absolute() else (manifest_dir / path).resolve()
|
||||
|
||||
|
||||
def _manifest_paths(value: object, manifest_dir: Path) -> list[Path]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
elif isinstance(value, list):
|
||||
values = value
|
||||
else:
|
||||
return []
|
||||
paths: list[Path] = []
|
||||
for item in values:
|
||||
path = _manifest_optional_path(item, manifest_dir)
|
||||
if path is not None:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def _primary_repo_path(paths: list[Path]) -> Path:
|
||||
if not paths:
|
||||
return Path(".").resolve()
|
||||
@@ -229,7 +430,9 @@ def _slugify(value: str) -> str:
|
||||
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", value.lower())).strip("-") or "repo"
|
||||
|
||||
|
||||
def _git_value(repo_path: Path, *args: str) -> str | None:
|
||||
def _git_value(repo_path: Path | None, *args: str) -> str | None:
|
||||
if repo_path is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
|
||||
Reference in New Issue
Block a user