feat: harden CASAN production install lifecycle
This commit is contained in:
@@ -63,7 +63,7 @@ function locateManifest(root, { manifestPath, projectId } = {}) {
|
||||
|
||||
const requestedProject = projectId || process.env.CASAN_PROJECT_ID;
|
||||
if (requestedProject) {
|
||||
const registryPath = join(root, 'packages/casan-harness/level5/project-registry.json');
|
||||
const registryPath = join(root, 'packages/casan-harness/config/project-registry.json');
|
||||
const registry = readJson(registryPath, 'project registry');
|
||||
const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject);
|
||||
if (!entry) fail(`project is not registered: ${requestedProject}`);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/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())
|
||||
@@ -16,7 +16,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
MANIFEST="packaging/levels.json"
|
||||
VERSION="$(cat VERSION 2>/dev/null || echo 0.0.0)"
|
||||
DIST="$ROOT/dist"
|
||||
DIST="${CASAN_DIST_DIR:-$ROOT/dist}"
|
||||
|
||||
BUNDLE="${1:-}"
|
||||
if [[ -z "$BUNDLE" ]]; then
|
||||
@@ -87,6 +87,18 @@ copy_path() { # <relpath>
|
||||
fi
|
||||
local dest="$PKGDIR/$1"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
case "$1" in
|
||||
packages/casan-harness)
|
||||
python3 "$ROOT/scripts/copy-runtime.py" \
|
||||
--source-root "$ROOT" --destination-root "$PKGDIR" --component harness
|
||||
return
|
||||
;;
|
||||
packages/casan-devkit)
|
||||
python3 "$ROOT/scripts/copy-runtime.py" \
|
||||
--source-root "$ROOT" --destination-root "$PKGDIR" --component devkit
|
||||
return
|
||||
;;
|
||||
esac
|
||||
# copy, dropping caches/artifacts
|
||||
if [[ -d "$src" ]]; then
|
||||
rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$src/" "$dest/" 2>/dev/null \
|
||||
|
||||
@@ -37,6 +37,19 @@ for path in "${required[@]}"; do
|
||||
}
|
||||
done
|
||||
|
||||
for path in \
|
||||
packages/casan-harness/tests \
|
||||
packages/casan-harness/level5 \
|
||||
packages/casan-harness/scripts/bash/ci-harness-gate.sh \
|
||||
packages/casan-harness/scripts/bash/security-gate.sh \
|
||||
packages/casan-harness/scripts/bash/test-integrity.py \
|
||||
packages/casan-devkit/tests; do
|
||||
[[ ! -e "$ROOT/$path" ]] || {
|
||||
echo "ARTIFACT_SMOKE_FAIL reason=source_only_path_present path=$path" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
(cd "$ROOT" && shasum -a 256 -c SHA256SUMS >/dev/null)
|
||||
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
CLI_VERSION="$(cd "$ROOT" && ./bin/casan version)"
|
||||
|
||||
Reference in New Issue
Block a user