148 lines
4.4 KiB
Python
Executable File
148 lines
4.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate and verify Agent OS bootstrap exports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import json
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
BOOTSTRAP_ROOT = Path(__file__).resolve().parent
|
|
DEFAULT_OUTPUT = BOOTSTRAP_ROOT / "exports" / "generic"
|
|
|
|
|
|
def load_json(path: Path) -> dict:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def parse_mode(mode: str) -> int:
|
|
return int(mode, 8)
|
|
|
|
|
|
def ensure_safe_output(output: Path) -> Path:
|
|
resolved = output.resolve()
|
|
if resolved.parts[-3:] != ("bootstrap", "exports", "generic"):
|
|
raise SystemExit(
|
|
"refusing to write outside a bootstrap/exports/generic directory: "
|
|
f"{resolved}"
|
|
)
|
|
return resolved
|
|
|
|
|
|
def copy_file(source: Path, target: Path, mode: str) -> None:
|
|
if not source.is_file():
|
|
raise FileNotFoundError(f"missing source file: {source}")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(source, target)
|
|
target.chmod(parse_mode(mode))
|
|
|
|
|
|
def clean_output(output: Path) -> None:
|
|
ensure_safe_output(output)
|
|
if output.exists():
|
|
shutil.rmtree(output)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def create_directories(output: Path, manifest: dict) -> None:
|
|
for directory in manifest["directories"]:
|
|
target = output / directory["path"]
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
if directory.get("keep"):
|
|
keep_file = target / ".gitkeep"
|
|
keep_file.touch()
|
|
keep_file.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
|
|
|
|
|
|
def export_bundle(output: Path, manifest: dict, rules: dict) -> None:
|
|
clean_output(output)
|
|
create_directories(output, manifest)
|
|
|
|
for item in rules["metadata"]:
|
|
copy_file(
|
|
BOOTSTRAP_ROOT / item["source"],
|
|
output / item["target"],
|
|
item.get("mode", "0644"),
|
|
)
|
|
|
|
template_root = BOOTSTRAP_ROOT / rules["template_root"]
|
|
for item in manifest["templates"]:
|
|
copy_file(
|
|
template_root / item["source"],
|
|
output / item["target"],
|
|
item.get("mode", "0644"),
|
|
)
|
|
|
|
|
|
def verify_required(output: Path, rules: dict) -> list[str]:
|
|
failures: list[str] = []
|
|
for item in rules["verify"]["required"]:
|
|
target = output / item["path"]
|
|
expected_type = item["type"]
|
|
if expected_type == "file" and not target.is_file():
|
|
failures.append(f"missing file: {item['path']}")
|
|
elif expected_type == "directory" and not target.is_dir():
|
|
failures.append(f"missing directory: {item['path']}")
|
|
return failures
|
|
|
|
|
|
def verify_forbidden(output: Path, rules: dict) -> list[str]:
|
|
failures: list[str] = []
|
|
for path in output.rglob("*"):
|
|
rel = path.relative_to(output).as_posix()
|
|
for pattern in rules["verify"].get("forbidden", []):
|
|
if fnmatch.fnmatch(rel, pattern):
|
|
failures.append(f"forbidden path exported: {rel}")
|
|
return failures
|
|
|
|
|
|
def verify_export(output: Path, rules: dict) -> None:
|
|
failures = verify_required(output, rules) + verify_forbidden(output, rules)
|
|
if failures:
|
|
for failure in failures:
|
|
print(f"FAIL {failure}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def count_files(output: Path) -> int:
|
|
return sum(1 for path in output.rglob("*") if path.is_file())
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=DEFAULT_OUTPUT,
|
|
help="Export directory. Must end in bootstrap/exports/generic.",
|
|
)
|
|
parser.add_argument(
|
|
"--verify-only",
|
|
action="store_true",
|
|
help="Verify an existing export without regenerating it.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
output = ensure_safe_output(args.output)
|
|
manifest = load_json(BOOTSTRAP_ROOT / "bundle-manifest.json")
|
|
rules = load_json(BOOTSTRAP_ROOT / "export-rules.json")
|
|
|
|
if not args.verify_only:
|
|
export_bundle(output, manifest, rules)
|
|
|
|
verify_export(output, rules)
|
|
print(f"OK {manifest['bundle']['id']} -> {output}")
|
|
print(f"OK verified {len(rules['verify']['required'])} required paths")
|
|
print(f"OK exported {count_files(output)} files")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|