246 lines
7.9 KiB
Python
246 lines
7.9 KiB
Python
"""
|
|
Sovereign Orchestrator - ISO Builder
|
|
|
|
Generates answers.toml files and invokes `proxmox-auto-install-assistant`
|
|
to produce custom auto-installer ISOs. Build state is tracked in-memory
|
|
and exposed through the API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import toml
|
|
|
|
from app.config import settings
|
|
from app.models import BuildState, BuildStatus, ISOConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory build registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_builds: dict[str, BuildStatus] = {}
|
|
|
|
|
|
def get_all_builds() -> list[BuildStatus]:
|
|
"""Return all builds, newest first."""
|
|
return sorted(
|
|
_builds.values(), key=lambda b: b.created_at, reverse=True
|
|
)
|
|
|
|
|
|
def get_build(build_id: str) -> Optional[BuildStatus]:
|
|
"""Look up a single build by ID."""
|
|
return _builds.get(build_id)
|
|
|
|
|
|
def get_latest_build() -> Optional[BuildStatus]:
|
|
"""Return the most recent build, or None."""
|
|
builds = get_all_builds()
|
|
return builds[0] if builds else None
|
|
|
|
|
|
def get_active_builds() -> list[BuildStatus]:
|
|
"""Return builds that are still in progress."""
|
|
return [
|
|
b for b in _builds.values()
|
|
if b.state in (BuildState.PENDING, BuildState.BUILDING, BuildState.UPLOADING)
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# answers.toml generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_answers_toml(config: ISOConfig) -> str:
|
|
"""Render an ``answers.toml`` string from the given configuration.
|
|
|
|
The output format matches the Proxmox auto-installer specification
|
|
with [global], [network], and [disk-setup] sections.
|
|
"""
|
|
doc: dict = {
|
|
"global": {
|
|
"keyboard": config.keyboard,
|
|
"country": config.country,
|
|
"timezone": config.timezone,
|
|
"mailto": config.mailto,
|
|
"root-password": config.root_password,
|
|
"fqdn": config.fqdn,
|
|
"reboot-on-error": config.reboot_on_error,
|
|
},
|
|
"network": {
|
|
"source": config.network_source.value,
|
|
},
|
|
"disk-setup": {
|
|
"filesystem": config.filesystem.value,
|
|
"disk-list": config.disk_list,
|
|
},
|
|
}
|
|
|
|
if config.root_ssh_keys:
|
|
doc["global"]["root-ssh-keys"] = config.root_ssh_keys
|
|
|
|
return toml.dumps(doc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base ISO discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BASE_ISO_SEARCH_PATHS = [
|
|
Path(settings.iso_storage_path),
|
|
Path("/var/lib/vz/template/iso"),
|
|
Path("/usr/share/proxmox-installer"),
|
|
Path.home() / "isos",
|
|
]
|
|
|
|
|
|
def find_base_iso() -> Optional[Path]:
|
|
"""Locate the first Proxmox VE ISO in well-known directories.
|
|
|
|
Searches for files matching ``proxmox-ve_*.iso``.
|
|
"""
|
|
for search_dir in _BASE_ISO_SEARCH_PATHS:
|
|
if not search_dir.is_dir():
|
|
continue
|
|
candidates = sorted(
|
|
search_dir.glob("proxmox-ve_*.iso"), reverse=True
|
|
)
|
|
if candidates:
|
|
logger.info("Found base ISO: %s", candidates[0])
|
|
return candidates[0]
|
|
logger.warning("No base Proxmox VE ISO found in search paths")
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ISO build pipeline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ensure_build_dir() -> Path:
|
|
"""Create and return the build output directory."""
|
|
build_dir = Path(settings.build_output_path)
|
|
build_dir.mkdir(parents=True, exist_ok=True)
|
|
return build_dir
|
|
|
|
|
|
async def build_iso(config: ISOConfig) -> BuildStatus:
|
|
"""Kick off an asynchronous ISO build and return the BuildStatus.
|
|
|
|
The actual subprocess work runs in the background so the API can
|
|
return immediately.
|
|
"""
|
|
build = BuildStatus(iso_config=config)
|
|
_builds[build.id] = build
|
|
build.log(f"Build {build.id} created")
|
|
|
|
# Launch the background task
|
|
asyncio.create_task(_run_build(build))
|
|
|
|
return build
|
|
|
|
|
|
async def _run_build(build: BuildStatus) -> None:
|
|
"""Execute the full ISO build pipeline."""
|
|
try:
|
|
build.state = BuildState.BUILDING
|
|
build.log("Build started")
|
|
|
|
build_dir = _ensure_build_dir()
|
|
work_dir = build_dir / build.id
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 1. Write answers.toml
|
|
answers_path = work_dir / "answers.toml"
|
|
answers_content = generate_answers_toml(build.iso_config)
|
|
answers_path.write_text(answers_content)
|
|
build.log(f"answers.toml written to {answers_path}")
|
|
logger.debug("answers.toml content:\n%s", answers_content)
|
|
|
|
# 2. Locate base ISO
|
|
base_iso = find_base_iso()
|
|
if base_iso is None:
|
|
raise FileNotFoundError(
|
|
"No base Proxmox VE ISO found. Place a proxmox-ve_*.iso in "
|
|
f"{settings.iso_storage_path}"
|
|
)
|
|
build.log(f"Using base ISO: {base_iso}")
|
|
|
|
# 3. Determine output filename
|
|
fqdn_slug = build.iso_config.fqdn.replace(".", "-")
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
output_filename = f"pve-autoinstall-{fqdn_slug}-{timestamp}.iso"
|
|
output_path = work_dir / output_filename
|
|
|
|
# 4. Copy the base ISO to the output path first (prepare-iso works
|
|
# on a copy so we don't mutate the original)
|
|
build.log(f"Copying base ISO to {output_path}")
|
|
await asyncio.to_thread(shutil.copy2, str(base_iso), str(output_path))
|
|
build.log("Base ISO copied")
|
|
|
|
# 5. Run proxmox-auto-install-assistant prepare-iso
|
|
cmd = [
|
|
"proxmox-auto-install-assistant",
|
|
"prepare-iso",
|
|
str(output_path),
|
|
"--fetch-from", "iso",
|
|
"--answer-file", str(answers_path),
|
|
]
|
|
build.log(f"Running: {' '.join(cmd)}")
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
cwd=str(work_dir),
|
|
)
|
|
|
|
# Stream output into build logs
|
|
assert proc.stdout is not None
|
|
while True:
|
|
line = await proc.stdout.readline()
|
|
if not line:
|
|
break
|
|
decoded = line.decode("utf-8", errors="replace").rstrip()
|
|
build.log(decoded)
|
|
logger.debug("[build %s] %s", build.id, decoded)
|
|
|
|
return_code = await proc.wait()
|
|
|
|
if return_code != 0:
|
|
raise RuntimeError(
|
|
f"proxmox-auto-install-assistant exited with code {return_code}"
|
|
)
|
|
|
|
prepared_filename = output_filename.replace(".iso", "-auto-from-iso.iso")
|
|
build.iso_filename = prepared_filename
|
|
build.state = BuildState.COMPLETED
|
|
build.log(f"Build completed: {prepared_filename}")
|
|
logger.info("Build %s completed: %s", build.id, work_dir / prepared_filename)
|
|
|
|
except Exception as exc:
|
|
build.state = BuildState.FAILED
|
|
build.error = str(exc)
|
|
build.log(f"Build failed: {exc}")
|
|
logger.exception("Build %s failed", build.id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utility: get the output ISO path for a build
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_build_iso_path(build_id: str) -> Optional[Path]:
|
|
"""Return the absolute path to the output ISO for a completed build."""
|
|
build = _builds.get(build_id)
|
|
if not build or not build.iso_filename:
|
|
return None
|
|
return Path(settings.build_output_path) / build.id / build.iso_filename
|