This commit is contained in:
2026-03-31 00:09:26 +02:00
parent 2ed43eab7a
commit 951623978b
8 changed files with 319 additions and 381 deletions

View File

@@ -21,14 +21,3 @@ load_env_file() {
set +a
}
maybe_fake_legacy() {
TOOL_NAME="$1"
if command -v "$TOOL_NAME" >/dev/null 2>&1; then
return 1
fi
if [ "${RETRODEBIAN_FAKE_LEGACY_TOOLS:-1}" = "1" ]; then
echo "[fake-legacy] $TOOL_NAME not found, emulating." >&2
return 0
fi
fail "$TOOL_NAME not found and RETRODEBIAN_FAKE_LEGACY_TOOLS != 1"
}

View File

@@ -12,11 +12,4 @@ load_env_file "$ENV_FILE"
[ -n "${PROFILE_ARTIFACT_DIR:-}" ] || fail "PROFILE_ARTIFACT_DIR is required"
mkdir -p "$PROFILE_ARTIFACT_DIR/final"
if maybe_fake_legacy lh_build; then
OUTPUT_FILE="$PROFILE_ARTIFACT_DIR/final/${PROFILE_NAME:-profile}.iso"
printf 'fake iso for %s
' "${PROFILE_NAME:-unknown}" > "$OUTPUT_FILE"
exit 0
fi
exec lh_build "$LIVE_DIR"

View File

@@ -11,11 +11,10 @@ load_env_file "$ENV_FILE"
[ -n "${LIVE_DIR:-}" ] || fail "LIVE_DIR is required"
mkdir -p "$LIVE_DIR"
if maybe_fake_legacy lh_config; then
mkdir -p "$LIVE_DIR/.fake-live-helper"
printf 'fake lh_config for %s
' "${PROFILE_NAME:-unknown}" > "$LIVE_DIR/.fake-live-helper/lh_config.txt"
exit 0
fi
exec lh_config "$LIVE_DIR"
exec lh_config \
--mirror-bootstrap $(BASE__REPO_URL) \
--mirror-binary $(BASE__REPO_URL) \
--mirror-binary-security $(BASE__SECURITY_REPO_URL) \
--mirror-chroot $(BASE__REPO_URL) \
--mirror-chroot-security $(BASE__SECURITY_REPO_URL) \
"$LIVE_DIR"

View File

@@ -16,7 +16,14 @@ if str(THIS_DIR) not in sys.path:
from retrobuilder.context import BuildContext
from retrobuilder.envfile import write_env_file
from retrobuilder.loader import load_base_chain, load_base_entry, load_base_spec, load_feature_entry, load_feature_spec, load_profile
from retrobuilder.loader import (
load_base_chain,
load_module_entry,
load_module_spec,
load_profile,
resolve_module_config,
resolve_module_docker_override,
)
from retrobuilder.operations import (
apply_profile_common_configuration,
clear_directory_contents,
@@ -39,7 +46,7 @@ DEFAULT_RUNTIME_DOCKERFILES = {
}
def run(cmd: list[str], *, cwd: Path | None = None, capture: bool = False) -> str:
def sh(cmd: list[str], *, cwd: Path | None = None, capture: bool = False) -> str:
proc = subprocess.run(cmd, cwd=cwd, text=True, check=True, stdout=subprocess.PIPE if capture else None)
return proc.stdout.strip() if capture else ""
@@ -64,8 +71,8 @@ class RuntimeConfig:
class DockerRuntime:
def __init__(self, project_root: Path, name: str, image: str) -> None:
self.project_root = project_root
def __init__(self, root: Path, name: str, image: str) -> None:
self.root = root
self.name = name
self.image = image
self.container = f"retrodebian-{name}-{uuid.uuid4().hex[:8]}"
@@ -75,171 +82,126 @@ class DockerRuntime:
if self.started:
return
user = f"{os.getuid()}:{os.getgid()}" if hasattr(os, "getuid") else "0:0"
run([
sh([
"docker", "run", "-d", "--name", self.container,
"--user", user,
"-w", str(self.project_root),
"-v", f"{self.project_root}:{self.project_root}:rw",
self.image,
"sleep", "infinity",
"-w", str(self.root),
"-v", f"{self.root}:{self.root}:rw",
self.image, "sleep", "infinity",
])
self.started = True
def exec(self, argv: list[str], env: dict[str, str] | None = None) -> None:
self.start()
cmd = ["docker", "exec"]
for key, value in (env or {}).items():
cmd.extend(["-e", f"{key}={value}"])
cmd.append(self.container)
cmd.extend(argv)
run(cmd)
for k, v in (env or {}).items():
cmd += ["-e", f"{k}={v}"]
sh(cmd + [self.container] + argv)
def stop(self) -> None:
if self.started:
try:
run(["docker", "stop", self.container])
sh(["docker", "stop", self.container])
finally:
self.started = False
def rm(self) -> None:
run(["docker", "rm", "-f", self.container])
sh(["docker", "rm", "-f", self.container])
class RuntimePool:
def __init__(self, root: Path, configs: dict[str, RuntimeConfig], keep: bool = False) -> None:
self.root = root
self.keep = keep
self.configs = configs
self.defaults = {name: DockerRuntime(root, name, self._resolve_image(name, cfg)) for name, cfg in configs.items()}
self.defaults = {name: DockerRuntime(root, name, self._image(name, cfg)) for name, cfg in configs.items()}
def _resolve_image(self, name: str, cfg: RuntimeConfig) -> str:
def _image(self, name: str, cfg: RuntimeConfig) -> str:
if cfg.dockerfile:
image = cfg.image or f"retrodebian/{name}:local"
run(["docker", "build", "-t", image, "-f", cfg.dockerfile, cfg.context or "."], cwd=self.root)
sh(["docker", "build", "-t", image, "-f", cfg.dockerfile, cfg.context or "."], cwd=self.root)
return image
if cfg.image:
return cfg.image
raise ValueError(f"Missing runtime image for {name}")
raise ValueError(f"Missing image for runtime {name}")
def start_defaults(self) -> None:
def start(self) -> None:
for runtime in self.defaults.values():
runtime.start()
def close(self) -> None:
for runtime in self.defaults.values():
try:
runtime.stop()
except Exception:
pass
try: runtime.stop()
except Exception: pass
if not self.keep:
for runtime in self.defaults.values():
try:
runtime.rm()
except Exception:
pass
try: runtime.rm()
except Exception: pass
def get(self, runtime_name: str, override: RuntimeConfig | None = None) -> DockerRuntime:
def resolve(self, runtime_name: str, override: RuntimeConfig | None = None) -> tuple[DockerRuntime, bool]:
if not override or not (override.image or override.dockerfile):
return self.defaults[runtime_name]
image = self._resolve_image(f"{runtime_name}-override-{uuid.uuid4().hex[:6]}", override)
return DockerRuntime(self.root, f"{runtime_name}-override", image)
def ctx(root: Path, phase: str, kind: str, profile_name: str = "", base_name: str = "", feature_name: str = "") -> BuildContext:
profile = load_profile(root, profile_name) if profile_name else None
current_name = feature_name or base_name or profile_name
return BuildContext(
project_root=root,
live_dir=root / "live",
artifacts_root=root / "artifacts",
phase=phase,
current_kind=kind,
current_name=current_name,
current_module_artifact_dir=(
feature_artifacts_dir(root, feature_name) if kind == "feature" else
base_artifacts_dir(root, base_name) if kind == "base" else
profile_artifacts_dir(root, profile_name)
),
profile_name=profile_name,
profile_artifact_dir=profile_artifacts_dir(root, profile_name) if profile_name else None,
base_name=base_name,
base_artifact_dir=base_artifacts_dir(root, base_name) if base_name else None,
feature_name=feature_name,
feature_artifact_dir=feature_artifacts_dir(root, feature_name) if feature_name else None,
profile_features=tuple(profile.features) if profile else (),
)
def env_for(context: BuildContext, fake_legacy: bool) -> dict[str, str]:
env = context.to_env()
env["RETRODEBIAN_FAKE_LEGACY_TOOLS"] = "1" if fake_legacy else os.environ.get("RETRODEBIAN_FAKE_LEGACY_TOOLS", "0")
return env
def write_phase_env(context: BuildContext, path: Path, fake_legacy: bool) -> Path:
write_env_file(path, env_for(context, fake_legacy))
return path
def load_module(kind: str, root: Path, name: str):
return (load_base_spec(root, name), load_base_entry(root, name), base_dir(root, name)) if kind == "base" else (load_feature_spec(root, name), load_feature_entry(root, name), feature_dir(root, name))
def override_for(spec, phase: str) -> RuntimeConfig | None:
stage = spec.docker_overrides.get(phase)
if not stage:
return None
return RuntimeConfig(stage.image or "", stage.dockerfile or "", stage.docker_context or ".")
return self.defaults[runtime_name], False
runtime = DockerRuntime(self.root, f"{runtime_name}-override", self._image(f"{runtime_name}-override-{uuid.uuid4().hex[:6]}", override))
runtime.start()
return runtime, True
class Orchestrator:
def __init__(self, root: Path, runtimes: RuntimePool, fake_legacy: bool = False) -> None:
def __init__(self, root: Path, runtimes: RuntimePool) -> None:
self.root = root
self.runtimes = runtimes
self.fake_legacy = fake_legacy
def validate(self) -> None:
for profile_name in list_names(self.root / "profiles"):
profile = load_profile(self.root, profile_name)
load_base_chain(self.root, profile.base)
for feature_name in profile.features:
load_feature_spec(self.root, feature_name)
load_module_spec(self.root, "feature", feature_name)
print("Validation OK")
def python_phase(self, kind: str, name: str, phase: str, *, profile: str = "", base: str = "", feature: str = "") -> None:
spec, _entry, module_dir = load_module(kind, self.root, name)
runtime = self.runtimes.get(ORCHESTRATOR_RUNTIME, override_for(spec, phase))
owned = runtime is not self.runtimes.defaults[ORCHESTRATOR_RUNTIME]
if owned:
runtime.start()
def context(self, phase: str, kind: str, *, profile_name: str = "", base_name: str = "", feature_name: str = "") -> BuildContext:
profile = load_profile(self.root, profile_name) if profile_name else None
base_name = base_name or (profile.base if profile else "")
feature_names = tuple(profile.features) if profile else ()
return BuildContext(
project_root=self.root,
live_dir=self.root / "live",
artifacts_root=self.root / "artifacts",
phase=phase,
kind=kind,
name=feature_name or base_name or profile_name,
profile_name=profile_name,
base_name=base_name,
feature_name=feature_name,
profile={**(profile.config if profile else {}), "base": base_name, "features": feature_names, "edition": profile.edition if profile else "", "description": profile.description if profile else "", "splash": profile.splash if profile else ""},
base=resolve_module_config(self.root, "base", base_name) if base_name else {},
features={name: resolve_module_config(self.root, "feature", name) for name in feature_names},
)
def env_file(self, ctx: BuildContext, path: Path) -> Path:
values = ctx.to_env()
write_env_file(path, values)
return path
def override(self, kind: str, name: str, phase: str) -> RuntimeConfig | None:
stage = resolve_module_docker_override(self.root, kind, name, phase)
if not stage:
return None
return RuntimeConfig(stage.image, stage.dockerfile, stage.docker_context or ".")
def run_python(self, kind: str, name: str, phase: str, *, profile_name: str = "", base_name: str = "", feature_name: str = "") -> None:
module_dir = (base_dir if kind == "base" else feature_dir)(self.root, name)
runtime, owned = self.runtimes.resolve(ORCHESTRATOR_RUNTIME, self.override(kind, name, phase))
try:
runtime.exec(["python3", str(module_dir / "entry.py"), phase], env_for(ctx(self.root, phase, kind, profile, base, feature), self.fake_legacy))
runtime.exec(["python3", str(module_dir / "entry.py"), phase], self.context(phase, kind, profile_name=profile_name, base_name=base_name, feature_name=feature_name).to_env())
finally:
if owned:
runtime.stop()
if not self.runtimes.keep:
runtime.rm()
def shell_phase(self, kind: str, name: str, phase: str, env_path: Path) -> None:
spec, _entry, module_dir = load_module(kind, self.root, name)
runtime = self.runtimes.get(LIVE_HELPER_RUNTIME, override_for(spec, phase))
owned = runtime is not self.runtimes.defaults[LIVE_HELPER_RUNTIME]
if owned:
runtime.start()
try:
runtime.exec(["builder/bash/run_entry.sh", str(module_dir), str(env_path), phase])
finally:
if owned:
runtime.stop()
if not self.runtimes.keep:
runtime.rm()
def generate(self, kind: str, name: str, env_path: Path) -> None:
spec, _entry, module_dir = load_module(kind, self.root, name)
runtime = self.runtimes.get(PACKAGE_BUILDER_RUNTIME, override_for(spec, "generate"))
owned = runtime is not self.runtimes.defaults[PACKAGE_BUILDER_RUNTIME]
if owned:
runtime.start()
def run_generate(self, kind: str, name: str, env_path: Path) -> None:
module_dir = (base_dir if kind == "base" else feature_dir)(self.root, name)
runtime, owned = self.runtimes.resolve(PACKAGE_BUILDER_RUNTIME, self.override(kind, name, "generate"))
try:
runtime.exec(["builder/bash/run_generate.sh", str(module_dir), str(env_path)])
finally:
@@ -248,63 +210,65 @@ class Orchestrator:
if not self.runtimes.keep:
runtime.rm()
def run_profile_config(self, profile: str, env_path: Path) -> None:
self.runtimes.defaults[LIVE_HELPER_RUNTIME].exec(["builder/bash/run_profile_config.sh", str(env_path)])
def run_profile_build(self, profile: str, env_path: Path) -> None:
self.runtimes.defaults[LIVE_HELPER_RUNTIME].exec(["builder/bash/run_profile_build.sh", str(env_path)])
def run_shell(self, kind: str, name: str, phase: str, env_path: Path) -> None:
module_dir = (base_dir if kind == "base" else feature_dir)(self.root, name)
runtime, owned = self.runtimes.resolve(LIVE_HELPER_RUNTIME, self.override(kind, name, phase))
try:
runtime.exec(["builder/bash/run_entry.sh", str(module_dir), str(env_path), phase])
finally:
if owned:
runtime.stop()
if not self.runtimes.keep:
runtime.rm()
def build_common_features(self, profile_hint: str) -> None:
for name in list_names(self.root / "features"):
pre = ctx(self.root, "pre-gen", "feature", profile_hint, feature_name=name)
self.python_phase("feature", name, "pre-gen", profile=profile_hint, feature=name)
self.generate("feature", name, write_phase_env(pre, feature_artifacts_dir(self.root, name) / "runtime.env", self.fake_legacy))
self.python_phase("feature", name, "post-gen", profile=profile_hint, feature=name)
for feature_name in list_names(self.root / "features"):
ctx = self.context("pre-gen", "feature", profile_name=profile_hint, feature_name=feature_name)
self.run_python("feature", feature_name, "pre-gen", profile_name=profile_hint, feature_name=feature_name)
self.run_generate("feature", feature_name, self.env_file(ctx, feature_artifacts_dir(self.root, feature_name) / "runtime.env"))
self.run_python("feature", feature_name, "post-gen", profile_name=profile_hint, feature_name=feature_name)
def build_base_chain(self, profile_name: str, chain: list[tuple[str, object]]) -> None:
for name, _spec in chain:
pre = ctx(self.root, "pre-gen", "base", profile_name, base_name=name)
self.python_phase("base", name, "pre-gen", profile=profile_name, base=name)
self.generate("base", name, write_phase_env(pre, base_artifacts_dir(self.root, name) / "runtime.env", self.fake_legacy))
self.python_phase("base", name, "post-gen", profile=profile_name, base=name)
def build_base_chain(self, profile_name: str, base_chain: list[tuple[str, object]]) -> None:
for base_name, _ in base_chain:
ctx = self.context("pre-gen", "base", profile_name=profile_name, base_name=base_name)
self.run_python("base", base_name, "pre-gen", profile_name=profile_name, base_name=base_name)
self.run_generate("base", base_name, self.env_file(ctx, base_artifacts_dir(self.root, base_name) / "runtime.env"))
self.run_python("base", base_name, "post-gen", profile_name=profile_name, base_name=base_name)
def configure_profile(self, profile_name: str, profile, base_chain: list[tuple[str, object]]) -> None:
live_dir = self.root / "live"
clear_directory_contents(live_dir)
ensure_live_structure(live_dir)
save_profile_metadata(profile_artifacts_dir(self.root, profile_name) / "profile.json", profile_name, profile, profile.base, load_base_spec(self.root, profile.base), base_chain)
config_ctx = ctx(self.root, "config", "profile", profile_name)
self.run_profile_config(profile_name, write_phase_env(config_ctx, profile_artifacts_dir(self.root, profile_name) / "profile-config.env", self.fake_legacy))
apply_profile_common_configuration(self.root, live_dir, profile_name, profile)
clear_directory_contents(self.root / "live")
ensure_live_structure(self.root / "live")
save_profile_metadata(profile_artifacts_dir(self.root, profile_name) / "profile.json", profile_name, profile, profile.base, load_module_spec(self.root, "base", profile.base), base_chain)
self.runtimes.defaults[LIVE_HELPER_RUNTIME].exec(["builder/bash/run_profile_config.sh", str(self.env_file(self.context("config", "profile", profile_name=profile_name), profile_artifacts_dir(self.root, profile_name) / "profile-config.env"))])
apply_profile_common_configuration(self.root, self.root / "live", profile_name, profile)
for base_name, _ in base_chain:
inject_module_resources(base_dir(self.root, base_name), live_dir, base_name)
inject_module_resources(base_dir(self.root, base_name), self.root / "live", base_name)
def inject_features(self, profile_name: str, base_name: str, feature_names: Iterable[str]) -> None:
pre_feature_ctx = ctx(self.root, "pre-feature", "base", profile_name, base_name=base_name)
pre_feature_env = write_phase_env(pre_feature_ctx, base_artifacts_dir(self.root, base_name) / "pre-feature.env", self.fake_legacy)
self.python_phase("base", base_name, "pre-feature", profile=profile_name, base=base_name)
self.shell_phase("base", base_name, "pre-feature", pre_feature_env)
pre = self.context("pre-feature", "base", profile_name=profile_name, base_name=base_name)
pre_env = self.env_file(pre, base_artifacts_dir(self.root, base_name) / "pre-feature.env")
self.run_python("base", base_name, "pre-feature", profile_name=profile_name, base_name=base_name)
self.run_shell("base", base_name, "pre-feature", pre_env)
for feature_name in feature_names:
feature_spec = load_feature_spec(self.root, feature_name)
save_feature_metadata(profile_artifacts_dir(self.root, profile_name) / "features" / f"{feature_name}.json", feature_name, feature_spec)
pre_ctx = ctx(self.root, "pre-inj", "feature", profile_name, feature_name=feature_name)
pre_env = write_phase_env(pre_ctx, feature_artifacts_dir(self.root, feature_name) / "pre-inj.env", self.fake_legacy)
self.python_phase("feature", feature_name, "pre-inj", profile=profile_name, feature=feature_name)
self.shell_phase("feature", feature_name, "pre-inj", pre_env)
save_feature_metadata(profile_artifacts_dir(self.root, profile_name) / "features" / f"{feature_name}.json", feature_name, load_module_spec(self.root, "feature", feature_name))
pre = self.context("pre-inj", "feature", profile_name=profile_name, base_name=base_name, feature_name=feature_name)
pre_env = self.env_file(pre, feature_artifacts_dir(self.root, feature_name) / "pre-inj.env")
self.run_python("feature", feature_name, "pre-inj", profile_name=profile_name, base_name=base_name, feature_name=feature_name)
self.run_shell("feature", feature_name, "pre-inj", pre_env)
inject_module_resources(feature_dir(self.root, feature_name), self.root / "live", feature_name)
post_ctx = ctx(self.root, "post-inj", "feature", profile_name, feature_name=feature_name)
post_env = write_phase_env(post_ctx, feature_artifacts_dir(self.root, feature_name) / "post-inj.env", self.fake_legacy)
self.shell_phase("feature", feature_name, "post-inj", post_env)
self.python_phase("feature", feature_name, "post-inj", profile=profile_name, feature=feature_name)
post_feature_ctx = ctx(self.root, "post-feature", "base", profile_name, base_name=base_name)
post_feature_env = write_phase_env(post_feature_ctx, base_artifacts_dir(self.root, base_name) / "post-feature.env", self.fake_legacy)
self.python_phase("base", base_name, "post-feature", profile=profile_name, base=base_name)
self.shell_phase("base", base_name, "post-feature", post_feature_env)
post = self.context("post-inj", "feature", profile_name=profile_name, base_name=base_name, feature_name=feature_name)
post_env = self.env_file(post, feature_artifacts_dir(self.root, feature_name) / "post-inj.env")
self.run_shell("feature", feature_name, "post-inj", post_env)
self.run_python("feature", feature_name, "post-inj", profile_name=profile_name, base_name=base_name, feature_name=feature_name)
post = self.context("post-feature", "base", profile_name=profile_name, base_name=base_name)
post_env = self.env_file(post, base_artifacts_dir(self.root, base_name) / "post-feature.env")
self.run_python("base", base_name, "post-feature", profile_name=profile_name, base_name=base_name)
self.run_shell("base", base_name, "post-feature", post_env)
def finalize_profile(self, profile_name: str) -> None:
profile_pre_build(self.root / "live", profile_name)
build_ctx = ctx(self.root, "build", "profile", profile_name)
self.run_profile_build(profile_name, write_phase_env(build_ctx, profile_artifacts_dir(self.root, profile_name) / "profile-build.env", self.fake_legacy))
build_env = self.env_file(self.context("build", "profile", profile_name=profile_name), profile_artifacts_dir(self.root, profile_name) / "profile-build.env")
self.runtimes.defaults[LIVE_HELPER_RUNTIME].exec(["builder/bash/run_profile_build.sh", str(build_env)])
profile_finalize(self.root, profile_name)
def build_profile(self, profile_name: str) -> None:
@@ -315,14 +279,12 @@ class Orchestrator:
self.inject_features(profile_name, profile.base, profile.features)
self.finalize_profile(profile_name)
def run(self, profile_name: str | None, all_profiles: bool) -> None:
self.runtimes.start_defaults()
def run(self, all_profiles: bool, profile_name: str = "") -> None:
self.runtimes.start()
try:
self.validate()
hint = profile_name or "demo"
self.build_common_features(hint)
names = list_names(self.root / "profiles") if all_profiles else [profile_name]
for name in names:
self.build_common_features(profile_name or "demo")
for name in (list_names(self.root / "profiles") if all_profiles else [profile_name]):
self.build_profile(name)
finally:
self.runtimes.close()
@@ -337,28 +299,29 @@ def parser() -> argparse.ArgumentParser:
grp.add_argument("--profile")
grp.add_argument("--all-profiles", action="store_true")
for runtime in (ORCHESTRATOR_RUNTIME, PACKAGE_BUILDER_RUNTIME, LIVE_HELPER_RUNTIME):
arg = runtime.replace("-", "_")
runp.add_argument(f"--{arg}-image", default="")
runp.add_argument(f"--{arg}-dockerfile", default=DEFAULT_RUNTIME_DOCKERFILES[runtime])
runp.add_argument(f"--{arg}-context", default=".")
runp.add_argument("--keep-containers", action="store_true")
runp.add_argument("--fake-legacy", action="store_true")
opt = runtime.replace("-", "_")
runp.add_argument(f"--{opt}-image", default="")
runp.add_argument(f"--{opt}-dockerfile", default=DEFAULT_RUNTIME_DOCKERFILES[runtime])
runp.add_argument(f"--{opt}-context", default=".")
return p
def main() -> int:
args = parser().parse_args()
root = root_dir()
if args.command == "validate":
Orchestrator(root, RuntimePool(root, {
ORCHESTRATOR_RUNTIME: RuntimeConfig(image="noop"),
PACKAGE_BUILDER_RUNTIME: RuntimeConfig(image="noop"),
LIVE_HELPER_RUNTIME: RuntimeConfig(image="noop"),
}, keep=True)).validate()
return 0
runtimes = RuntimePool(root, {
ORCHESTRATOR_RUNTIME: RuntimeConfig(args.orchestrator_image, args.orchestrator_dockerfile, args.orchestrator_context),
PACKAGE_BUILDER_RUNTIME: RuntimeConfig(args.package_builder_image, args.package_builder_dockerfile, args.package_builder_context),
LIVE_HELPER_RUNTIME: RuntimeConfig(args.live_helper_image, args.live_helper_dockerfile, args.live_helper_context),
}, keep=args.keep_containers)
orch = Orchestrator(root, runtimes, fake_legacy=args.fake_legacy)
if args.command == "validate":
orch.validate()
else:
orch.run(args.profile, args.all_profiles)
Orchestrator(root, runtimes).run(args.all_profiles, args.profile or "")
return 0

View File

@@ -1,8 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Mapping
from typing import Any, Mapping
def _norm(name: str) -> str:
return "".join(ch if ch.isalnum() else "_" for ch in name.upper())
class Node(dict):
__getattr__ = dict.get
@classmethod
def wrap(cls, value: Any) -> Any:
if isinstance(value, dict):
return cls({k: cls.wrap(v) for k, v in value.items()})
if isinstance(value, (list, tuple)):
return [cls.wrap(v) for v in value]
return value
@dataclass(frozen=True)
@@ -11,59 +27,91 @@ class BuildContext:
live_dir: Path
artifacts_root: Path
phase: str
current_kind: str
current_name: str
current_module_artifact_dir: Path
profile_name: str = ''
profile_artifact_dir: Path | None = None
base_name: str = ''
base_artifact_dir: Path | None = None
feature_name: str = ''
feature_artifact_dir: Path | None = None
profile_features: tuple[str, ...] = ()
kind: str
name: str
profile_name: str = ""
base_name: str = ""
feature_name: str = ""
profile: Mapping[str, Any] = field(default_factory=dict)
base: Mapping[str, Any] = field(default_factory=dict)
features: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
def __post_init__(self) -> None:
object.__setattr__(self, "profile", Node.wrap(dict(self.profile)))
object.__setattr__(self, "base", Node.wrap(dict(self.base)))
object.__setattr__(self, "features", {k: Node.wrap(dict(v)) for k, v in self.features.items()})
def to_env(self) -> dict[str, str]:
env = {
'PROJECT_ROOT': str(self.project_root),
'LIVE_DIR': str(self.live_dir),
'ARTIFACTS_ROOT': str(self.artifacts_root),
'PHASE': self.phase,
'CURRENT_KIND': self.current_kind,
'CURRENT_NAME': self.current_name,
'CURRENT_MODULE_ARTIFACT_DIR': str(self.current_module_artifact_dir),
'PROFILE_NAME': self.profile_name,
'BASE_NAME': self.base_name,
'FEATURE_NAME': self.feature_name,
'PROFILE_FEATURES': '|'.join(self.profile_features),
"PROJECT_ROOT": str(self.project_root),
"LIVE_DIR": str(self.live_dir),
"ARTIFACTS_ROOT": str(self.artifacts_root),
"PHASE": self.phase,
"CURRENT_KIND": self.kind,
"CURRENT_NAME": self.name,
"PROFILE_NAME": self.profile_name,
"BASE_NAME": self.base_name,
"FEATURE_NAME": self.feature_name,
"FEATURE_NAMES": "|".join(self.features.keys()),
}
if self.profile_artifact_dir is not None:
env['PROFILE_ARTIFACT_DIR'] = str(self.profile_artifact_dir)
if self.base_artifact_dir is not None:
env['BASE_ARTIFACT_DIR'] = str(self.base_artifact_dir)
if self.feature_artifact_dir is not None:
env['FEATURE_ARTIFACT_DIR'] = str(self.feature_artifact_dir)
env.update(_flatten("PROFILE", self.profile))
env.update(_flatten("BASE", self.base))
for feat_name, feat_cfg in self.features.items():
env.update(_flatten(f"FEAT__{_norm(feat_name)}", feat_cfg))
return env
@classmethod
def from_env(cls, env: Mapping[str, str]) -> 'BuildContext':
def p(name: str) -> Path | None:
value = env.get(name, '')
return Path(value) if value else None
features = tuple(filter(None, env.get('PROFILE_FEATURES', '').split('|')))
def from_env(cls, env: Mapping[str, str]) -> "BuildContext":
feature_names = [x for x in env.get("FEATURE_NAMES", "").split("|") if x]
profile = _inflate("PROFILE", env)
base = _inflate("BASE", env)
features = {name: _inflate(f"FEAT__{_norm(name)}", env) for name in feature_names}
return cls(
project_root=Path(env['PROJECT_ROOT']),
live_dir=Path(env['LIVE_DIR']),
artifacts_root=Path(env['ARTIFACTS_ROOT']),
phase=env['PHASE'],
current_kind=env['CURRENT_KIND'],
current_name=env['CURRENT_NAME'],
current_module_artifact_dir=Path(env['CURRENT_MODULE_ARTIFACT_DIR']),
profile_name=env.get('PROFILE_NAME', ''),
profile_artifact_dir=p('PROFILE_ARTIFACT_DIR'),
base_name=env.get('BASE_NAME', ''),
base_artifact_dir=p('BASE_ARTIFACT_DIR'),
feature_name=env.get('FEATURE_NAME', ''),
feature_artifact_dir=p('FEATURE_ARTIFACT_DIR'),
profile_features=features,
project_root=Path(env["PROJECT_ROOT"]),
live_dir=Path(env["LIVE_DIR"]),
artifacts_root=Path(env["ARTIFACTS_ROOT"]),
phase=env["PHASE"],
kind=env["CURRENT_KIND"],
name=env["CURRENT_NAME"],
profile_name=env.get("PROFILE_NAME", ""),
base_name=env.get("BASE_NAME", ""),
feature_name=env.get("FEATURE_NAME", ""),
profile=profile,
base=base,
features=features,
)
def _stringify(value: Any) -> str:
if isinstance(value, bool):
return "1" if value else "0"
if value is None:
return ""
if isinstance(value, (list, tuple)):
return "|".join(_stringify(v) for v in value)
return str(value)
def _flatten(prefix: str, data: Mapping[str, Any]) -> dict[str, str]:
out: dict[str, str] = {}
for key, value in data.items():
name = f"{prefix}__{_norm(str(key))}"
if isinstance(value, dict):
out.update(_flatten(name, value))
else:
out[name] = _stringify(value)
return out
def _inflate(prefix: str, env: Mapping[str, str]) -> dict[str, Any]:
root: dict[str, Any] = {}
marker = f"{prefix}__"
for key, value in env.items():
if not key.startswith(marker):
continue
parts = key[len(marker):].split("__")
node = root
for part in parts[:-1]:
node = node.setdefault(part.lower(), {})
node[parts[-1].lower()] = value
return root

View File

@@ -1,57 +1,38 @@
from __future__ import annotations
import json
import os
import sys
from abc import ABC
from pathlib import Path
from retrobuilder.context import BuildContext
class BaseEntry(ABC):
def pre_gen(self, ctx: BuildContext) -> None:
return None
def post_gen(self, ctx: BuildContext) -> None:
return None
def pre_feature(self, ctx: BuildContext) -> None:
return None
def post_feature(self, ctx: BuildContext) -> None:
return None
def pre_gen(self, ctx: BuildContext) -> None: return None
def post_gen(self, ctx: BuildContext) -> None: return None
def pre_feature(self, ctx: BuildContext) -> None: return None
def post_feature(self, ctx: BuildContext) -> None: return None
class FeatureEntry(ABC):
def pre_gen(self, ctx: BuildContext) -> None:
return None
def post_gen(self, ctx: BuildContext) -> None:
return None
def pre_inj(self, ctx: BuildContext) -> None:
return None
def post_inj(self, ctx: BuildContext) -> None:
return None
def pre_gen(self, ctx: BuildContext) -> None: return None
def post_gen(self, ctx: BuildContext) -> None: return None
def pre_inj(self, ctx: BuildContext) -> None: return None
def post_inj(self, ctx: BuildContext) -> None: return None
def cli_dispatch(spec, entry_cls) -> int:
if len(sys.argv) < 2:
print('Usage: entry.py <spec|phase>', file=sys.stderr)
print("Usage: entry.py <spec|phase>", file=sys.stderr)
return 2
command = sys.argv[1]
if command == 'spec':
print(json.dumps(spec.to_dict(), indent=2, sort_keys=True))
if command == "spec":
print(spec.to_dict())
return 0
ctx = BuildContext.from_env(dict(__import__('os').environ))
entry = entry_cls()
method_name = command.replace('-', '_')
method = getattr(entry, method_name, None)
method = getattr(entry, command.replace("-", "_"), None)
if method is None:
print(f'Unsupported phase: {command}', file=sys.stderr)
print(f"Unsupported phase: {command}", file=sys.stderr)
return 2
method(ctx)
method(BuildContext.from_env(os.environ))
return 0

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import importlib.util
from pathlib import Path
from types import ModuleType
from typing import Any
from retrobuilder.model import BaseSpec, FeatureSpec, ProfileSpec
from retrobuilder.paths import base_dir, feature_dir, profile_file
@@ -11,71 +12,71 @@ from retrobuilder.paths import base_dir, feature_dir, profile_file
def _load_module(path: Path, name: str) -> ModuleType:
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f'Cannot load module from {path}')
raise RuntimeError(f"Cannot load module from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_profile(root: Path, name: str) -> ProfileSpec:
path = profile_file(root, name)
module = _load_module(path, f'profile_{name}')
profile = getattr(module, 'PROFILE', None)
module = _load_module(profile_file(root, name), f"profile_{name}")
profile = getattr(module, "PROFILE", None)
if not isinstance(profile, ProfileSpec):
raise TypeError(f'{path} must define PROFILE as ProfileSpec')
raise TypeError(f"profiles/{name}.py must define PROFILE as ProfileSpec")
return profile
def load_base_module(root: Path, name: str) -> ModuleType:
return _load_module(base_dir(root, name) / 'entry.py', f'base_{name}')
def _load_module_py(root: Path, kind: str, name: str) -> ModuleType:
path = (base_dir if kind == "base" else feature_dir)(root, name) / "entry.py"
return _load_module(path, f"{kind}_{name}")
def load_feature_module(root: Path, name: str) -> ModuleType:
return _load_module(feature_dir(root, name) / 'entry.py', f'feature_{name}')
def load_base_spec(root: Path, name: str) -> BaseSpec:
module = load_base_module(root, name)
spec = getattr(module, 'SPEC', None)
if not isinstance(spec, BaseSpec):
raise TypeError(f'bases/{name}/entry.py must define SPEC as BaseSpec')
def load_module_spec(root: Path, kind: str, name: str):
module = _load_module_py(root, kind, name)
spec = getattr(module, "SPEC", None)
expected = BaseSpec if kind == "base" else FeatureSpec
if not isinstance(spec, expected):
raise TypeError(f"{kind}s/{name}/entry.py must define SPEC as {expected.__name__}")
return spec
def load_base_chain(root: Path, name: str) -> list[tuple[str, BaseSpec]]:
chain: list[tuple[str, BaseSpec]] = []
def load_module_entry(root: Path, kind: str, name: str):
module = _load_module_py(root, kind, name)
entry = getattr(module, "Entry", None)
if entry is None:
raise TypeError(f"{kind}s/{name}/entry.py must define Entry")
return entry
def load_base_chain(root: Path, name: str):
return load_module_chain(root, "base", name)
def load_module_chain(root: Path, kind: str, name: str):
chain: list[tuple[str, Any]] = []
seen: set[str] = set()
current = name
while current:
if current in seen:
raise RuntimeError(f'Base inheritance loop detected at {current}')
raise RuntimeError(f"{kind} inheritance loop detected at {current}")
seen.add(current)
spec = load_base_spec(root, current)
spec = load_module_spec(root, kind, current)
chain.append((current, spec))
current = spec.parent or ''
current = spec.parent or ""
chain.reverse()
return chain
def load_feature_spec(root: Path, name: str) -> FeatureSpec:
module = load_feature_module(root, name)
spec = getattr(module, 'SPEC', None)
if not isinstance(spec, FeatureSpec):
raise TypeError(f'features/{name}/entry.py must define SPEC as FeatureSpec')
return spec
def resolve_module_config(root: Path, kind: str, name: str) -> dict[str, Any]:
merged: dict[str, Any] = {}
for _name, spec in load_module_chain(root, kind, name):
merged.update(spec.config)
return merged
def load_base_entry(root: Path, name: str):
module = load_base_module(root, name)
entry_cls = getattr(module, 'Entry', None)
if entry_cls is None:
raise TypeError(f'bases/{name}/entry.py must define Entry')
return entry_cls
def load_feature_entry(root: Path, name: str):
module = load_feature_module(root, name)
entry_cls = getattr(module, 'Entry', None)
if entry_cls is None:
raise TypeError(f'features/{name}/entry.py must define Entry')
return entry_cls
def resolve_module_docker_override(root: Path, kind: str, name: str, phase: str):
resolved = None
for _name, spec in load_module_chain(root, kind, name):
if phase in spec.docker_overrides:
resolved = spec.docker_overrides[phase]
return resolved

View File

@@ -1,69 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Any
from dataclasses import asdict, dataclass, field
from typing import Any, Mapping
@dataclass(frozen=True)
class DockerStageSpec:
image: str | None = None
dockerfile: str | None = None
docker_context: str | None = None
image: str = ""
dockerfile: str = ""
docker_context: str = "."
def __post_init__(self) -> None:
if self.image is not None and not isinstance(self.image, str):
raise TypeError('DockerStageSpec.image must be str or None')
if self.dockerfile is not None and not isinstance(self.dockerfile, str):
raise TypeError('DockerStageSpec.dockerfile must be str or None')
if self.docker_context is not None and not isinstance(self.docker_context, str):
raise TypeError('DockerStageSpec.docker_context must be str or None')
def is_set(self) -> bool:
return bool(self.image or self.dockerfile)
@classmethod
def from_value(cls, value: Any) -> 'DockerStageSpec':
if isinstance(value, DockerStageSpec):
return value
if isinstance(value, dict):
return cls(
image=value.get('image'),
dockerfile=value.get('dockerfile'),
docker_context=value.get('docker_context'),
)
raise TypeError(f'Expected DockerStageSpec or dict, got {type(value)!r}')
@dataclass(frozen=True)
class ModuleSpec:
description: str = ""
parent: str = ""
docker_overrides: Mapping[str, DockerStageSpec] = field(default_factory=dict)
config: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _docker_overrides(values: Any) -> dict[str, DockerStageSpec]:
if values is None:
return {}
if not isinstance(values, dict):
raise TypeError(f'Expected dict/None for docker_overrides, got {type(values)!r}')
result: dict[str, DockerStageSpec] = {}
for phase, item in values.items():
if not isinstance(phase, str):
raise TypeError('docker_overrides keys must be strings')
result[phase] = DockerStageSpec.from_value(item)
return result
@dataclass(frozen=True)
class ModuleSpec:
description: str = ''
docker_overrides: dict[str, DockerStageSpec] = field(default_factory=dict)
def __post_init__(self) -> None:
object.__setattr__(self, 'docker_overrides', _docker_overrides(self.docker_overrides))
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
payload['docker_overrides'] = {name: spec.to_dict() for name, spec in self.docker_overrides.items()}
return payload
@dataclass(frozen=True)
class BaseSpec(ModuleSpec):
parent: str | None = None
BASE__REPO_URL: str = "http://archive.debian.org/debian/"
BASE__SECURITY_REPO_URL: str = "http://archive.debian.org/debian-security/"
@dataclass(frozen=True)
@@ -74,16 +39,15 @@ class FeatureSpec(ModuleSpec):
@dataclass(frozen=True)
class ProfileSpec:
base: str
features: tuple[str, ...] = field(default_factory=tuple)
edition: str = ''
description: str = ''
splash: str | None = None
features: tuple[str, ...] = ()
edition: str = ""
description: str = ""
splash: str = ""
config: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if isinstance(self.features, list):
object.__setattr__(self, 'features', tuple(str(v) for v in self.features))
elif not isinstance(self.features, tuple):
raise TypeError('ProfileSpec.features must be list or tuple')
if not isinstance(self.features, tuple):
object.__setattr__(self, "features", tuple(self.features))
def to_dict(self) -> dict[str, Any]:
return asdict(self)