Files
CASAN/scripts/copy-runtime.py

206 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Copy one CASAN production component through the canonical runtime allowlist."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import shutil
import sys
IGNORED_NAMES = {
"__pycache__", ".DS_Store", "node_modules", "dist", "build", "coverage",
}
IGNORED_SUFFIXES = {".pyc", ".pyo", ".log", ".tmp"}
class LayoutError(RuntimeError):
pass
def load_layout(source_root: Path) -> dict:
path = source_root / "packaging" / "runtime-layout.json"
try:
layout = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as error:
raise LayoutError(f"cannot load {path}: {error}") from error
if layout.get("schema_version") != 1:
raise LayoutError("runtime layout schema_version must be 1")
return layout
def is_excluded(relative: Path, exclusions: set[str]) -> bool:
normalized = relative.as_posix()
if any(part in IGNORED_NAMES for part in relative.parts):
return True
if relative.suffix in IGNORED_SUFFIXES:
return True
return any(
normalized == excluded or normalized.startswith(excluded.rstrip("/") + "/")
for excluded in exclusions
)
def transformed_content(path: Path, transform: str) -> bytes:
if transform != "empty_project_registry":
raise LayoutError(f"unknown runtime transform: {transform}")
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as error:
raise LayoutError(f"cannot transform project registry: {error}") from error
payload["projects"] = []
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
def copy_component(
source_root: Path,
destination_root: Path,
name: str,
preserved: set[str] | None = None,
clean: bool = False) -> dict:
layout = load_layout(source_root)
component = layout.get("components", {}).get(name)
if not isinstance(component, dict):
raise LayoutError(f"unknown runtime component: {name}")
relative_source = Path(str(component.get("source", "")))
source = (source_root / relative_source).resolve()
destination = destination_root / relative_source
if not source.is_dir():
raise LayoutError(f"component source is missing: {source}")
if source_root not in source.parents:
raise LayoutError(f"component source escapes source root: {source}")
exclusions = {
str(path).replace("\\", "/").strip("/")
for path in component.get("exclude", [])
if isinstance(path, str) and path.strip("/")
}
unsafe = [
path for path in exclusions
if Path(path).is_absolute() or ".." in Path(path).parts
]
if unsafe:
raise LayoutError(
f"runtime component {name} has unsafe exclusions: {', '.join(unsafe)}")
transforms = component.get("transforms", {})
if not isinstance(transforms, dict):
raise LayoutError(f"runtime component {name} transforms must be an object")
removed = 0
if clean and destination.exists():
for excluded in sorted(exclusions, reverse=True):
candidate = destination / excluded
if candidate.is_dir() and not candidate.is_symlink():
shutil.rmtree(candidate)
removed += 1
elif candidate.exists() or candidate.is_symlink():
candidate.unlink()
removed += 1
for candidate in sorted(destination.rglob("*"), reverse=True):
if (candidate.name in IGNORED_NAMES or
(candidate.is_file() and candidate.suffix in IGNORED_SUFFIXES)):
if candidate.is_dir() and not candidate.is_symlink():
shutil.rmtree(candidate)
else:
candidate.unlink()
removed += 1
copied = 0
skipped = 0
preserved = preserved or set()
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
if is_excluded(relative, exclusions):
skipped += path.is_file()
continue
target = destination / relative
if path.is_dir():
continue
if not path.is_file():
continue
target_relative = (relative_source / relative).as_posix()
if target_relative in preserved and target.exists():
skipped += 1
continue
target.parent.mkdir(parents=True, exist_ok=True)
transform = transforms.get(relative.as_posix())
if transform:
target.write_bytes(transformed_content(path, str(transform)))
shutil.copymode(path, target)
else:
shutil.copy2(path, target)
copied += 1
if clean and destination.exists():
for candidate in sorted(destination.rglob("*"), reverse=True):
if candidate.is_dir() and not candidate.is_symlink():
try:
candidate.rmdir()
except OSError:
pass
missing = [
required for required in component.get("required", [])
if not (destination / required).is_file()
]
if missing:
raise LayoutError(
f"runtime component {name} is incomplete; missing: {', '.join(missing)}")
return {
"component": name,
"source": str(source),
"destination": str(destination),
"copied_files": copied,
"excluded_files": skipped,
"removed_paths": removed,
}
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--source-root", required=True)
value.add_argument("--destination-root", required=True)
value.add_argument("--component", action="append", required=True)
value.add_argument(
"--preserve", action="append", default=[],
help="destination-root-relative file to retain when it already exists")
value.add_argument(
"--clean", action="store_true",
help="remove excluded source-only paths left by an older installation")
value.add_argument("--json", action="store_true")
return value
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
source_root = Path(args.source_root).expanduser().resolve()
destination_root = Path(args.destination_root).expanduser().resolve()
destination_root.mkdir(parents=True, exist_ok=True)
preserved = {
str(path).replace("\\", "/").strip("/")
for path in args.preserve
if str(path).strip("/")
}
try:
results = [
copy_component(
source_root, destination_root, component, preserved, args.clean)
for component in args.component
]
except LayoutError as error:
sys.stderr.write(f"copy-runtime: {error}\n")
return 1
if args.json:
print(json.dumps({"status": "ok", "components": results}, indent=2))
else:
for result in results:
print(
"copy-runtime: {component} copied={copied_files} "
"excluded={excluded_files} removed={removed_paths}".format(**result))
return 0
if __name__ == "__main__":
raise SystemExit(main())