Source code for eb_staged_rebuild.engine

"""Build complete staged installs and transactionally swap them into place."""

import errno
import json
import os
import shutil
import stat
import time
from dataclasses import dataclass, field

from eb_staged_rebuild.atomicio import (
    atomic_write,
    fsync_dir,
    read_bytes,
    read_metadata,
    sha256_bytes,
)
from eb_staged_rebuild import outbox
from eb_staged_rebuild.ledger_adapter import EbStackLedger, load_config, publication_records
from eb_staged_rebuild.builders import (
    bwrap_command,
    dry_run_command,
    format_command,
    installpath_command,
    installpath_dirs,
    run_command,
    userns_command,
)
from eb_staged_rebuild.discover import (
    map_link_target,
    order_module_files,
    swap_install_for,
    walk_module_tree,
)
from eb_staged_rebuild.constants import DEFAULT_GRACE_HOURS, RUN_ID_TIMESTAMP_FORMAT
from eb_staged_rebuild.cleanup import remove_generation_tree
from eb_staged_rebuild.errors import NeedsIntervention, Refused
from eb_staged_rebuild.faults import FaultInjector
from eb_staged_rebuild.journal import (
    SCHEMA,
    generation_root as generation_root_for,
    journal_path,
    load_journal,
    run_dir as run_dir_for,
    save_journal,
)
from eb_staged_rebuild.livecheck import empty_real_dir, same_snapshot, snapshot
from eb_staged_rebuild.lockfile import FileLock
from eb_staged_rebuild.modulefile import install_complete, generation_complete, parse_root
from eb_staged_rebuild.paths import (
    abspath,
    assert_real_components,
    is_real_dir,
    is_within,
    lstat_kind,
)
from eb_staged_rebuild.probe import probe_all, stage_paths_from_bwrap_info
from eb_staged_rebuild.swap import recover_interrupted_swap, swap_directories

import re

_MODULE_LINE = re.compile(r"^\s*\*\s+\[[ xFR-]\]\s+.*\(module:\s*(.+)\)\s*$")


[docs] @dataclass class Spec: software: str modules: str state_root: str eb_args: list backend: str = "auto" grace_hours: float = DEFAULT_GRACE_HOURS dry_run: bool = False module_rels: list = field(default_factory=list) eb_executable: str = "eb" builder: object = None faults: object = None run_id: str = None skip_live_check: bool = False lock_timeout: float = None clock: object = None probes: dict = None prepare_executable: str = None ledger_config: str = None ledger_policy: dict = None
[docs] @dataclass class Result: returncode: int run_id: str = None state: str = "" message: str = "" command: str = ""
def _clock(spec): return spec.clock if spec.clock is not None else time.time def _faults(spec): return spec.faults if spec.faults is not None else FaultInjector() def _log(message): print(f"eb-staged-rebuild: {message}", flush=True) class _Ctx: def __init__(self, spec, generation_root, faults): self.software = spec.software self.modules = spec.modules self.generation_root = generation_root self.eb_executable = spec.eb_executable self.eb_args = list(spec.eb_args) self.faults = faults self.backend = spec.backend self.probes = spec.probes def _check_roots(spec): if spec.prepare_executable is not None and not spec.prepare_executable: raise Refused("publication preparation executable must not be empty") if spec.prepare_executable is not None and not os.path.isabs(spec.prepare_executable): raise Refused("publication preparation executable must be an absolute path") if not is_real_dir(spec.software): kind = lstat_kind(spec.software) if kind == "symlink": raise Refused( f"software path is a symlink ({spec.software}); " "Name/version must stay a real directory" ) raise Refused(f"software path is not a real directory: {spec.software} ({kind})") if not is_real_dir(spec.modules): kind = lstat_kind(spec.modules) if kind == "symlink": raise Refused(f"modules path is a symlink: {spec.modules}") raise Refused(f"modules path is not a real directory: {spec.modules} ({kind})") reason = assert_real_components(spec.state_root) if reason: raise Refused(f"state root is unsafe: {reason}") if spec.backend not in ("auto", "installpath", "bwrap", "userns"): raise Refused(f"unknown backend {spec.backend}") def _check_install_dir(software, rel): path = abspath(os.path.join(software, rel)) if path == abspath(software) or not is_within(path, software): raise Refused(f"module path escapes the software root: {rel}") if lstat_kind(path) == "symlink": raise Refused( f"{path} is a symlink. Name/version must stay a real directory: " "bubblewrap 0.12 and later refuse to mount on a symlink, and " "bubblewrap 0.6.3 follows one. This tool depends on neither." ) reason = assert_real_components(path) if reason: raise Refused(f"{rel}: {reason}. Name/version must stay a real directory") kind = lstat_kind(path) if kind != "dir": raise Refused(f"{path} is a {kind}, not an existing rebuild directory")
[docs] def software_rels_from_dry_run(text): rels = [] for line in text.splitlines(): match = _MODULE_LINE.match(line) if not match: continue field_text = match.group(1).strip() if " | " in field_text: subdir, short = field_text.split(" | ", 1) rels.append(os.path.join(subdir.strip(), short.strip())) else: rels.append(field_text) return rels
def _learn_rels(spec, faults, log): rels = [] for rel in spec.module_rels: if rel not in rels: rels.append(rel) if spec.builder is not None: return rels ctx = _Ctx(spec, generation_root_for(spec.state_root, "dry-run"), faults) cmd, env = dry_run_command(ctx) log("module preflight: " + format_command(cmd)) import subprocess completed = subprocess.run(cmd, env=env, capture_output=True, text=True, check=False) found = software_rels_from_dry_run((completed.stdout or "") + "\n" + (completed.stderr or "")) if completed.returncode != 0 and spec.backend == "bwrap" and not found and not rels: tail = (completed.stderr or completed.stdout or "").strip() raise Refused( "bwrap backend did not get a module list from eb --dry-run, so it will not " f"mount on an unknown path (eb exited {completed.returncode}). {tail}" ) for rel in found: if rel not in rels: rels.append(rel) if not rels: raise Refused( "run needs --module Name/version or an eb --dry-run module line; " "refusing to build for an unknown live path" ) return rels def _new_run_id(spec): if spec.run_id: return spec.run_id stamp = time.strftime(RUN_ID_TIMESTAMP_FORMAT, time.gmtime(_clock(spec)())) base = f"{stamp}-{os.getpid()}" candidate = base suffix = 0 while os.path.exists(run_dir_for(spec.state_root, candidate)): suffix += 1 candidate = f"{base}-{suffix}" return candidate def _snapshots(spec, rels): if spec.skip_live_check: return {} recorded = {} for rel in rels: recorded[rel] = snapshot(os.path.join(spec.software, rel)) return recorded def _enforce_manifest(spec, journal): if spec.skip_live_check: return changed = [] for rel, before in (journal.get("manifests") or {}).items(): path = os.path.join(spec.software, rel) if ( spec.backend == "bwrap" and before.get("exists") is False and empty_real_dir(path) ): try: os.rmdir(path) except OSError: pass after = snapshot(path) if not same_snapshot(before, after): changed.append(rel) if changed: raise Refused( "live software directory changed during the build; not published: " + ", ".join(changed) ) def _read_bwrap_info(generation_root): path = os.path.join(generation_root, "bwrap_info.json") if not os.path.isfile(path) or os.path.islink(path): raise Refused(f"bwrap backend finished without {path}; nothing was published") try: info = json.loads(read_bytes(path).decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise Refused(f"cannot read {path}: {exc}") from exc return info def _stage_roots(spec, journal, generation_root): if spec.backend in ("installpath", "userns"): return ( os.path.join(generation_root, "software"), os.path.join(generation_root, "modules"), "installpath", ) info = _read_bwrap_info(generation_root) version = (journal.get("probes") or {}).get("easybuild", {}).get("framework_version") software, modules, how = stage_paths_from_bwrap_info(info, version, generation_root) for label, path in (("software", software), ("modules", modules)): if not is_within(path, generation_root): raise Refused(f"bwrap stage {label} path escapes this run: {path}") return software, modules, how def _plan(spec, journal, faults): stage_software, stage_modules, how = _stage_roots(spec, journal, journal["generation_root"]) journal["stage_software"] = abspath(stage_software) journal["stage_modules"] = abspath(stage_modules) journal["stage_how"] = how if not is_real_dir(stage_software): raise Refused(f"stage software tree is not a real directory: {stage_software}") files, links = walk_module_tree(stage_modules) if not files: raise Refused(f"no module files under {stage_modules}") ordered, texts = order_module_files(files, stage_modules) drafted = [] for path in ordered: rel = os.path.relpath(path, stage_modules).replace(os.sep, "/") staged, live_software, new_text = swap_install_for( texts[path], stage_software, spec.software, ) if not generation_complete(staged): raise Refused(f"{staged} is not a complete staged installation") if os.path.islink(staged): raise Refused(f"staged installation {staged} is a symlink") if lstat_kind(live_software) == "symlink": raise Refused( f"{live_software} is a symlink. Name/version must stay a real directory" ) if lstat_kind(live_software) != "dir": raise Refused( f"staged rebuild target must already be a real directory: {live_software}" ) retired = os.path.join( os.path.dirname(live_software), f".{os.path.basename(live_software)}.{journal['run_id']}", ) if lstat_kind(retired) != "missing": raise Refused(f"retired path already exists: {retired}") live_module = os.path.join(spec.modules, rel) if not is_within(live_module, spec.modules): raise Refused(f"module path escapes the module root: {rel}") if lstat_kind(live_module) == "symlink": raise Refused( f"refusing to replace module symlink {live_module}. " "The module file that is the pointer must be a regular file" ) reason = assert_real_components(live_module) if reason: raise Refused(reason) if lstat_kind(live_module) not in ("missing", "file"): raise Refused(f"{live_module} is not a regular file") drafted.append((rel, staged, live_software, retired, new_text, live_module, path)) run_dir = run_dir_for(spec.state_root, journal["run_id"]) plan = [] for rel, staged, live_software, retired, new_text, live_module, staged_module in drafted: prepared = os.path.join(run_dir, "prepared", rel) metadata_source = live_module if lstat_kind(live_module) == "file" else staged_module atomic_write( prepared, new_text, faults, f"prepare:{rel}", metadata_from=metadata_source, ) stamp = { "run_id": journal["run_id"], "staged": staged, "live": live_software, "module": rel, "sha256": sha256_bytes(new_text.encode("utf-8")), } _write_into_sealed_directory( staged, lambda: atomic_write( os.path.join(staged, ".eb-staged-rebuild-complete"), json.dumps(stamp, indent=2, sort_keys=True) + "\n", faults, f"stamp:{rel}", ), ) existed = lstat_kind(live_module) == "file" old_hash = None previous_root = None if existed: old_bytes = read_bytes(live_module) old_hash = sha256_bytes(old_bytes) _kind, previous_root = parse_root(old_bytes.decode("utf-8")) if previous_root and abspath(previous_root) != abspath(live_software): raise Refused( f"live module {live_module} points at {previous_root}, " f"not its stable install path {live_software}" ) plan.append( { "rel": rel, "live_module": live_module, "prepared": prepared, "staged": staged, "live_software": live_software, "retired": retired, "backup": os.path.join(run_dir, "backups", rel), "new_hash": sha256_bytes(new_text.encode("utf-8")), "old_hash": old_hash, "previous_root": previous_root, "existed": existed, "status": "pending", "swap_method": None, "published_at": None, } ) link_plan = [] for rel, target in links: link_plan.append( { "rel": rel, "live_path": os.path.join(spec.modules, rel), "target": map_link_target(target, stage_modules, spec.modules), "status": "pending", "created": False, } ) journal["modules_plan"] = plan journal["links_plan"] = link_plan def _write_into_sealed_directory(directory, write): """Run ``write`` with ``directory`` writable, then put its mode back. EasyBuild's ``read-only-installdir`` seals a finished install; the stamp still has to land beside it. The directory's own mode is the only thing that changes, and only for the duration of the write. """ info = os.stat(directory) mode = stat.S_IMODE(info.st_mode) if mode & stat.S_IWUSR: return write() os.chmod(directory, mode | stat.S_IWUSR | stat.S_IXUSR) try: return write() finally: os.chmod(directory, mode) def _move_sealed_directory(source, destination): """Rename a directory to another parent, sealed or not. Moving a directory rewrites its ``..`` entry, so Linux wants write permission on the directory itself. A read-only install gets it for the rename alone and has its mode back at the new name. """ mode = stat.S_IMODE(os.stat(source).st_mode) if mode & stat.S_IWUSR: os.rename(source, destination) return os.chmod(source, mode | stat.S_IWUSR | stat.S_IXUSR) moved = False try: os.rename(source, destination) moved = True finally: os.chmod(destination if moved else source, mode) def _tree_run_id(path): stamp = os.path.join(path, ".eb-staged-rebuild-complete") if lstat_kind(stamp) != "file": return None try: value = json.loads(read_bytes(stamp).decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): return None return value.get("run_id") if isinstance(value, dict) else None def _allow_exchange(journal): return bool((journal.get("probes") or {}).get("rename_exchange", {}).get("ok")) def _prepare_module_backup(mod, faults): live = mod["live_module"] kind = lstat_kind(live) if kind == "symlink": raise Refused(f"refusing to replace module symlink {live}") if mod["existed"]: if kind != "file": raise NeedsIntervention(f"{live} disappeared before publish") current = read_bytes(live) current_hash = sha256_bytes(current) backup = mod["backup"] if current_hash == mod["new_hash"] and lstat_kind(backup) == "file": if sha256_bytes(read_bytes(backup)) != mod["old_hash"]: raise NeedsIntervention( f"backup for {mod['rel']} does not match the previous module file" ) return if current_hash != mod["old_hash"]: raise NeedsIntervention(f"{live} changed after the build") if lstat_kind(backup) == "missing": atomic_write( backup, current, faults, f"publish.backup:{mod['rel']}", metadata_from=live, ) elif lstat_kind(backup) != "file" or sha256_bytes(read_bytes(backup)) != mod["old_hash"]: raise NeedsIntervention( f"backup for {mod['rel']} does not match the previous module file" ) elif kind == "file" and sha256_bytes(read_bytes(live)) == mod["new_hash"]: return elif kind != "missing": raise NeedsIntervention(f"new module path appeared before publish: {live}") def _prepare_swap_source(mod, run_id, faults): staged = mod["staged"] retired = mod["retired"] staged_kind = lstat_kind(staged) retired_kind = lstat_kind(retired) if staged_kind == "dir" and retired_kind == "missing": faults.reach(f"stage.move:{mod['rel']}") try: _move_sealed_directory(staged, retired) except OSError as error: if error.errno == errno.EXDEV: raise Refused( "the stage and live software tree are on different filesystems; " "place --state-root beside the software tree" ) from error raise fsync_dir(os.path.dirname(retired)) elif staged_kind == "missing" and retired_kind == "dir": pass else: raise NeedsIntervention( f"cannot prepare {mod['rel']}: staged={staged_kind}, retired={retired_kind}" ) # After an exchange the journal did not record, the run's tree is live and # the retired name holds the old tree; the baseline step swaps them back. if _tree_run_id(retired) != run_id and _tree_run_id(mod["live_software"]) != run_id: raise NeedsIntervention(f"prepared tree does not belong to run {run_id}: {retired}") def _write_new_module(mod, faults): prepared = read_bytes(mod["prepared"]) if sha256_bytes(prepared) != mod["new_hash"]: raise NeedsIntervention(f"prepared module bytes for {mod['rel']} changed") live = mod["live_module"] kind = lstat_kind(live) if kind == "file" and sha256_bytes(read_bytes(live)) == mod["new_hash"]: # Same bytes is not the same module: a publication-preparation # program may have set the access policy on the prepared file, and # that policy is published with it. if _same_access(live, mod["prepared"]): return if mod["existed"]: if kind != "file" or sha256_bytes(read_bytes(live)) != mod["old_hash"]: raise NeedsIntervention(f"{live} is not the previous module file") elif kind != "missing": raise NeedsIntervention(f"{live} appeared before it could be published") atomic_write( live, prepared, faults, f"publish.file:{mod['rel']}", metadata_from=mod["prepared"], ) def _same_access(left, right): """Whether two files carry the same owner, mode and ACL bytes.""" try: first, second = read_metadata(left), read_metadata(right) except (OSError, Refused): return False return ( (first.info.st_uid, first.info.st_gid, stat.S_IMODE(first.info.st_mode)) == (second.info.st_uid, second.info.st_gid, stat.S_IMODE(second.info.st_mode)) and first.acls == second.acls ) def _restore_old_module(mod, faults): live = mod["live_module"] kind = lstat_kind(live) if mod["existed"]: backup = mod["backup"] if lstat_kind(backup) != "file": raise NeedsIntervention(f"no backup to restore for {mod['rel']}") previous = read_bytes(backup) if sha256_bytes(previous) != mod["old_hash"]: raise NeedsIntervention(f"backup for {mod['rel']} changed") if kind == "file" and sha256_bytes(read_bytes(live)) == mod["old_hash"]: # The backup carries the previous access policy; the same bytes # under a published policy still have to go back. if _same_access(live, backup): return if kind != "file" or sha256_bytes(read_bytes(live)) != mod["new_hash"]: raise NeedsIntervention(f"{live} is neither the old nor new module file") atomic_write( live, previous, faults, f"restore.file:{mod['rel']}", metadata_from=backup, ) return if kind == "missing": return if kind == "file" and sha256_bytes(read_bytes(live)) == mod["new_hash"]: faults.reach(f"restore.remove:{mod['rel']}") os.remove(live) fsync_dir(os.path.dirname(live)) return raise NeedsIntervention(f"{live} is not the module file this run created") def _create_links(journal, faults): for link in journal.get("links_plan") or []: path = link["live_path"] target = link["target"] kind = lstat_kind(path) if kind == "symlink" and os.readlink(path) == target: link["status"] = "present" continue if kind != "missing": link["status"] = "kept" continue parent = os.path.dirname(path) reason = assert_real_components(parent) if reason: raise Refused(reason) os.makedirs(parent, exist_ok=True) faults.reach(f"links.create:{link['rel']}") os.symlink(target, path) link["created"] = True link["status"] = "created" def _remove_created_links(journal): for link in reversed(journal.get("links_plan") or []): if not link.get("created"): continue path = link["live_path"] if lstat_kind(path) == "symlink" and os.readlink(path) == link["target"]: os.unlink(path) fsync_dir(os.path.dirname(path)) link["created"] = False link["status"] = "pending" def _normalize_publish_baseline(journal): """Recover interrupted swaps and return every module to the old live tree.""" silent = FaultInjector() for mod in reversed(journal.get("modules_plan") or []): live = mod["live_software"] retired = mod["retired"] recover_interrupted_swap(live, retired) live_run = _tree_run_id(live) retired_run = _tree_run_id(retired) if live_run == journal["run_id"] and retired_run != journal["run_id"]: swap_directories( live, retired, allow_exchange=_allow_exchange(journal), faults=silent, step_prefix=f"recover:{mod['rel']}", ) elif live_run != journal["run_id"] and retired_run == journal["run_id"]: pass else: raise NeedsIntervention( f"cannot identify old and staged trees for {mod['rel']}: " f"live run={live_run!r}, retired run={retired_run!r}" ) _restore_old_module(mod, silent) mod["status"] = "pending" mod["swap_method"] = None mod["published_at"] = None _remove_created_links(journal) def _restore_publish_baseline(journal): try: _remove_created_links(journal) _normalize_publish_baseline(journal) except BaseException as error: raise NeedsIntervention( f"publish failed and the old live tree could not be restored: {error}" ) from error def _validate_publication_set(journal): """Refuse before any live name moves when prepared access metadata cannot be read.""" for mod in journal["modules_plan"]: try: prepared = read_bytes(mod["prepared"]) if sha256_bytes(prepared) != mod["new_hash"]: raise NeedsIntervention(f"prepared module bytes for {mod['rel']} changed") try: read_metadata(mod["prepared"]) except Refused as exc: raise Refused( f"cannot validate publication for {mod['rel']}: {exc}" ) from exc except OSError as exc: raise Refused(f"cannot validate publication for {mod['rel']}: {exc}") from exc def _recover_swap_names(journal): """Put an interrupted ordinary-rename fallback back before another prepare.""" for mod in journal.get("modules_plan") or []: if mod.get("live_software") and mod.get("retired"): recover_interrupted_swap(mod["live_software"], mod["retired"]) def _publish(spec, journal, faults): _recover_swap_names(journal) _validate_publication_set(journal) journal["state"] = "publishing" save_journal(journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.publishing") clock = _clock(spec) for mod in journal["modules_plan"]: _prepare_module_backup(mod, faults) _prepare_swap_source(mod, journal["run_id"], faults) save_journal( journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.prepared", ) try: _normalize_publish_baseline(journal) for mod in journal["modules_plan"]: result = swap_directories( mod["live_software"], mod["retired"], allow_exchange=_allow_exchange(journal), faults=faults, step_prefix=f"swap:{mod['rel']}", ) mod["status"] = "swapped" mod["swap_method"] = result.method mod["published_at"] = clock() _log(f"swapped {mod['rel']} ({result.method})") save_journal( journal_path(spec.state_root, journal["run_id"]), journal, faults, f"journal.swap:{mod['rel']}", ) for mod in journal["modules_plan"]: _write_new_module(mod, faults) mod["status"] = "published" _log(f"published module {mod['rel']}") save_journal( journal_path(spec.state_root, journal["run_id"]), journal, faults, f"journal.module:{mod['rel']}", ) _create_links(journal, faults) journal["state"] = "complete" journal["completed_at"] = clock() journal["message"] = "published" save_journal( journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.complete", ) except BaseException: _restore_publish_baseline(journal) journal["state"] = "built" journal["message"] = "publish interrupted; old live tree restored" save_journal( journal_path(spec.state_root, journal["run_id"]), journal, FaultInjector(), "journal.restored", ) raise def _clear_stage_modules(generation_root): """Drop module files left by an earlier attempt in this generation. EasyBuild deletes the install directory named by an existing module file when ``remove-ghost-install-dirs`` is on. A bubblewrap module file names the live directory. Removing the staged module files before ``eb`` runs means a resumed build does not hand EasyBuild that path. A symlink is not followed. """ modules = os.path.join(os.path.abspath(generation_root), "modules") if os.path.islink(modules): raise Refused(f"refusing to clear a symlinked module stage: {modules}") if os.path.isdir(modules): remove_generation_tree(modules, os.path.abspath(generation_root)) def _build(spec, journal, faults): journal["state"] = "building" save_journal(journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.building") ctx = _Ctx(spec, journal["generation_root"], faults) os.makedirs(journal["generation_root"], exist_ok=True) _clear_stage_modules(journal["generation_root"]) faults.reach("build.start") if spec.builder is not None: rc = spec.builder(ctx) journal["eb_command"] = None elif spec.backend == "bwrap": cmd, env = bwrap_command(ctx) journal["eb_command"] = cmd _log("running " + format_command(cmd)) rc = run_command(cmd, env, faults, "build.invoke") elif spec.backend == "userns": software, modules = installpath_dirs(journal["generation_root"]) os.makedirs(software, exist_ok=True) os.makedirs(modules, exist_ok=True) cmd, env, _software, _modules = userns_command(ctx) journal["eb_command"] = cmd _log("running " + format_command(cmd)) rc = run_command(cmd, env, faults, "build.invoke") else: software, modules = installpath_dirs(journal["generation_root"]) os.makedirs(software, exist_ok=True) os.makedirs(modules, exist_ok=True) cmd, env, _software, _modules = installpath_command(ctx) journal["eb_command"] = cmd _log("running " + format_command(cmd)) rc = run_command(cmd, env, faults, "build.invoke") faults.reach("build.finish") journal["eb_returncode"] = rc return rc def _fail(spec, journal, faults, message, code): journal["state"] = "failed" journal["message"] = message journal["updated_at"] = _clock(spec)() save_journal(journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.failed") _log(message) return Result(code, journal["run_id"], "failed", message, _command_text(journal)) def _command_text(journal): cmd = journal.get("eb_command") if not cmd: return "" return format_command(cmd) def _preparation_failure(spec, journal, faults, message, code): journal["message"] = message journal["prepare_returncode"] = code journal["updated_at"] = _clock(spec)() save_journal(journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.prepare_failed") _log(message) return code def _prepare_publication(spec, journal, faults): """Optional external permission step. It must not be required to rewrite roots.""" if not spec.prepare_executable or journal.get("prepare_complete"): return 0 context_path = os.path.join(run_dir_for(spec.state_root, journal["run_id"]), "prepare-context.json") context = { "run_id": journal["run_id"], "publication": "directory-swap", "generation_root": journal["generation_root"], "state_root": spec.state_root, "live_software": spec.software, "live_modules": spec.modules, "stage_software": journal["stage_software"], "stage_modules": journal["stage_modules"], "modules": [ { **{ key: mod[key] for key in ("rel", "staged", "live_software", "retired", "live_module", "prepared") }, # The staged tree under its generation name, for a finalizer # written against the generation-pointer context. "generation": mod["staged"], } for mod in journal["modules_plan"] ], } atomic_write(context_path, json.dumps(context, indent=2, sort_keys=True) + "\n", faults, "prepare.context") command = [spec.prepare_executable, context_path] _log("preparing publication permissions: " + format_command(command)) try: rc = run_command(command, os.environ.copy(), faults, "prepare.invoke") except OSError as exc: return _preparation_failure(spec, journal, faults, f"publication preparation failed: {exc}", 2) journal["prepare_returncode"] = rc if rc != 0: return _preparation_failure( spec, journal, faults, f"publication preparation exited {rc}; the live directory was not swapped", rc, ) # The program ran over every staged tree. One it left without build # evidence blocks the whole set: a swap of the others would publish a # module whose sibling is gone. for mod in journal["modules_plan"]: if not install_complete(mod["staged"]): return _preparation_failure( spec, journal, faults, f"publication preparation left {mod['rel']} incomplete at {mod['staged']}; " "the live directory was not swapped", 2, ) try: _validate_publication_set(journal) except (Refused, NeedsIntervention) as exc: return _preparation_failure(spec, journal, faults, str(exc), 2) journal["message"] = "" journal["prepare_complete"] = True save_journal(journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.prepared") return 0 def _publisher(journal): path = journal.get("ledger_config") if not path: return None policy = journal.get("ledger_policy") if policy is None: raise Refused("ledger policy is missing from the journal; inspect the publication before resuming") if load_config(path) != policy: raise Refused("ledger policy changed; restore the recorded configuration before resuming") return EbStackLedger(policy) def _record(journal, faults, publisher, records, step): run_dir = run_dir_for(journal["state_root"], journal["run_id"]) if records: outbox.enqueue(run_dir, records, faults, step) return outbox.deliver(run_dir, publisher, faults) def _pending_message(left): return f"{len(left)} ledger event(s) not acknowledged ({left[0]['error']}); run resume" def _record_finished(spec, journal, faults, publisher, message): if publisher is not None: finished = publication_records(publisher.config, journal, "finished", _clock(spec)()) left = _record(journal, faults, publisher, finished, "outbox.finished") if left: message = f"{message}; {_pending_message(left)}" _log(message) return Result(outbox.PENDING_EXIT, journal["run_id"], "complete", message, _command_text(journal)) return Result(0, journal["run_id"], "complete", message, _command_text(journal)) def _publish_recorded(spec, journal, faults): """Swap only after a required ledger has acknowledged every started record.""" rc = _prepare_publication(spec, journal, faults) if rc != 0: return Result(rc, journal["run_id"], "failed", journal["message"], _command_text(journal)) publisher = _publisher(journal) if publisher is not None: _validate_publication_set(journal) started = publication_records(publisher.config, journal, "started", _clock(spec)()) left = _record(journal, faults, publisher, started, "outbox.started") if left and publisher.required: journal["message"] = "the live directory was not swapped: " + _pending_message(left) save_journal( journal_path(spec.state_root, journal["run_id"]), journal, faults, "journal.ledger_pending", ) _log(journal["message"]) return Result( outbox.PENDING_EXIT, journal["run_id"], journal["state"], journal["message"], _command_text(journal), ) _publish(spec, journal, faults) message = f"run {journal['run_id']} published {len(journal['modules_plan'])} module(s)" _log(message) return _record_finished(spec, journal, faults, publisher, message) def _run_locked(spec, faults, journal, fresh_build): path = journal_path(spec.state_root, journal["run_id"]) if fresh_build: rc = _build(spec, journal, faults) if rc != 0: return _fail(spec, journal, faults, f"eb exited {rc}; live modules were not changed", rc or 1) try: _enforce_manifest(spec, journal) except Refused as exc: return _fail(spec, journal, faults, str(exc), 2) try: _plan(spec, journal, faults) except Refused as exc: return _fail(spec, journal, faults, str(exc), 2) journal["state"] = "built" journal["updated_at"] = _clock(spec)() save_journal(path, journal, faults, "journal.built") return _publish_recorded(spec, journal, faults) def _blank_journal(spec, run_id, probes, rels): now = _clock(spec)() return { "schema": SCHEMA, "run_id": run_id, "state": "created", "backend": spec.backend, "software": abspath(spec.software), "modules": abspath(spec.modules), "state_root": abspath(spec.state_root), "generation_root": abspath(generation_root_for(spec.state_root, run_id)), "eb_args": list(spec.eb_args), "eb_executable": spec.eb_executable, "grace_hours": spec.grace_hours, "skip_live_check": spec.skip_live_check, "prepare_executable": spec.prepare_executable, "ledger_config": spec.ledger_config, "ledger_policy": spec.ledger_policy, "prepare_complete": False, "prepare_returncode": None, "publication": "directory-swap", "module_rels": rels, "created_at": now, "updated_at": now, "manifests": _snapshots(spec, rels), "probes": probes, "modules_plan": [], "links_plan": [], "message": "", "eb_returncode": None, "eb_command": None, "retired_at": None, "generation_removed_at": None, } def _select_backend(spec, probes): requested = spec.backend if requested == "auto": if (probes.get("bwrap") or {}).get("usable"): selected = "bwrap" elif (probes.get("userns_sandbox") or {}).get("usable"): selected = "userns" else: selected = "installpath" spec.backend = selected return requested, selected return requested, requested
[docs] def run(spec): """Build into a new generation, then swap it with the live directory.""" _check_roots(spec) if spec.ledger_config is not None: spec.ledger_policy = load_config(spec.ledger_config) spec.ledger_config = spec.ledger_policy["path"] faults = _faults(spec) probes = spec.probes if spec.probes is not None else probe_all(spec.software, spec.eb_executable) spec.probes = probes requested_backend, selected_backend = _select_backend(spec, probes) if requested_backend == "auto" and selected_backend == "installpath": _log( "neither bubblewrap nor the user-namespace sandbox is usable; " "using the unsandboxed install-path fallback" ) if spec.dry_run: return _dry_run(spec, probes, faults) if spec.backend == "bwrap" and not (probes.get("bwrap") or {}).get("usable"): reason = (probes.get("bwrap") or {}).get("reason") or "bwrap is not usable" raise Refused(f"{reason}; refusing to rebuild in place") if spec.backend == "userns" and not (probes.get("userns_sandbox") or {}).get("usable"): reason = ( (probes.get("userns_sandbox") or {}).get("reason") or "the user-namespace sandbox is not usable" ) raise Refused(f"{reason}; refusing to run the userns backend") rels = _learn_rels(spec, faults, _log) for rel in rels: _check_install_dir(spec.software, rel) run_id = _new_run_id(spec) lock = FileLock(spec.state_root) lock.acquire(spec.lock_timeout) try: if os.path.exists(journal_path(spec.state_root, run_id)): raise Refused(f"run {run_id} already exists; resume it or choose another --run-id") os.makedirs(run_dir_for(spec.state_root, run_id), exist_ok=True) journal = _blank_journal(spec, run_id, probes, rels) save_journal(journal_path(spec.state_root, run_id), journal, faults, "journal.created") _log(f"run {run_id} backend {spec.backend} generation {journal['generation_root']}") return _run_locked(spec, faults, journal, fresh_build=True) finally: lock.release()
def _spec_from_journal(journal, builder, faults, clock, lock_timeout): return Spec( software=journal["software"], modules=journal["modules"], state_root=journal["state_root"], eb_args=list(journal.get("eb_args") or []), backend=journal["backend"], grace_hours=journal.get("grace_hours") or DEFAULT_GRACE_HOURS, module_rels=list(journal.get("module_rels") or []), eb_executable=journal.get("eb_executable") or "eb", builder=builder, faults=faults, run_id=journal["run_id"], skip_live_check=bool(journal.get("skip_live_check")), lock_timeout=lock_timeout, clock=clock, probes=journal.get("probes"), prepare_executable=journal.get("prepare_executable"), ledger_config=journal.get("ledger_config"), ledger_policy=journal.get("ledger_policy"), )
[docs] def resume(state_root, run_id, builder=None, faults=None, clock=None, lock_timeout=None): """Continue a run. A finished run is a no-op. A rolled-back run is refused.""" path = journal_path(state_root, run_id) journal = load_journal(path) if journal.get("kind") == "retire": raise Refused(f"run {run_id} is a retire record; rollback restores the module file") faults = faults or FaultInjector() spec = _spec_from_journal(journal, builder, faults, clock, lock_timeout) _check_roots(spec) lock = FileLock(spec.state_root) lock.acquire(lock_timeout) try: journal = load_journal(path) state = journal["state"] published = [mod for mod in journal.get("modules_plan") or [] if mod.get("status") == "published"] if state == "complete": message = f"run {run_id} is already complete" _log(message) return _record_finished(spec, journal, faults, _publisher(journal), message) if state == "rolled_back": raise Refused(f"run {run_id} was rolled back; start a new run") if state in ("created", "building", "failed"): if published: raise NeedsIntervention( f"run {run_id} is {state} but a module was already published" ) _log(f"resume {run_id} from {state}: rebuilding the unpublished generation") return _run_locked(spec, faults, journal, fresh_build=True) if state in ("built", "publishing"): _log(f"resume {run_id} from {state}: publishing") return _publish_recorded(spec, journal, faults) raise NeedsIntervention(f"run {run_id} is in an unknown state {state}") finally: lock.release()
def _restore_published_baseline(journal): """Undo a failed explicit rollback and return to the published generation.""" silent = FaultInjector() for mod in journal.get("modules_plan") or []: recover_interrupted_swap(mod["live_software"], mod["retired"]) live_run = _tree_run_id(mod["live_software"]) retired_run = _tree_run_id(mod["retired"]) if live_run != journal["run_id"] and retired_run == journal["run_id"]: swap_directories( mod["live_software"], mod["retired"], allow_exchange=_allow_exchange(journal), faults=silent, step_prefix=f"rollback-recover:{mod['rel']}", ) elif live_run == journal["run_id"] and retired_run != journal["run_id"]: pass else: raise NeedsIntervention(f"cannot restore published tree for {mod['rel']}") _write_new_module(mod, silent) mod["status"] = "published" _create_links(journal, silent) def _record_rollback(journal, faults, publisher, finished, started, now, message): """After the restore: finish the correction, or abort a publication that never finished.""" if publisher is None: return Result(0, journal["run_id"], "rolled_back", message) if finished: records = publication_records(publisher.config, journal, "finished", now(), corrects=finished) elif started: records = publication_records(publisher.config, journal, "aborted", now()) else: records = [] left = _record(journal, faults, publisher, records, "outbox.rollback_finished") if left: message = f"{message}; {_pending_message(left)}" _log(message) return Result(outbox.PENDING_EXIT, journal["run_id"], "rolled_back", message, _command_text(journal)) return Result(0, journal["run_id"], "rolled_back", message, _command_text(journal))
[docs] def rollback(state_root, run_id, faults=None, clock=None, lock_timeout=None): """Exchange every retired tree back into place as one recoverable operation.""" path = journal_path(state_root, run_id) faults = faults or FaultInjector() now = time.time if clock is None else clock lock = FileLock(state_root) lock.acquire(lock_timeout) try: journal = load_journal(path) if journal.get("kind") == "retire": from eb_staged_rebuild.retire import rollback_retire return rollback_retire(state_root, journal, faults, now) publisher = _publisher(journal) finished = {} started = False if publisher is not None: left = _record(journal, faults, publisher, [], None) if left and publisher.required: message = f"run {run_id} was not rolled back: {_pending_message(left)}" _log(message) return Result(outbox.PENDING_EXIT, run_id, journal["state"], message, _command_text(journal)) events = outbox.load(run_dir_for(state_root, run_id))["events"] finished = { event["record"]["build"]["module_file"]: event["receipt"]["id"] for event in events if event["status"] == "acknowledged" and event["record"]["action"] != "correction" and event["record"]["stage"] == "finished" } started = any(event["record"]["stage"] == "started" for event in events) if journal["state"] == "rolled_back" and all( mod.get("status") == "restored" for mod in journal.get("modules_plan") or [] ): message = f"run {run_id} is already rolled back" _log(message) return _record_rollback(journal, faults, publisher, finished, started, now, message) if journal["state"] != "complete": raise Refused(f"run {run_id} is {journal['state']}; only a complete run can roll back") if finished: records = publication_records(publisher.config, journal, "started", now(), corrects=finished) left = _record(journal, faults, publisher, records, "outbox.rollback_started") if left and publisher.required: message = f"run {run_id} was not rolled back: {_pending_message(left)}" _log(message) return Result(outbox.PENDING_EXIT, run_id, journal["state"], message, _command_text(journal)) try: for mod in reversed(journal.get("modules_plan") or []): recover_interrupted_swap(mod["live_software"], mod["retired"]) # An exchange the journal did not record already put the old # tree back: this run's tree is at the retired name. if ( _tree_run_id(mod["live_software"]) != journal["run_id"] and _tree_run_id(mod["retired"]) == journal["run_id"] ): method = mod.get("rollback_method") or "recovered" else: method = swap_directories( mod["live_software"], mod["retired"], allow_exchange=_allow_exchange(journal), faults=faults, step_prefix=f"rollback.swap:{mod['rel']}", ).method mod["status"] = "restoring" mod["rollback_method"] = method save_journal(path, journal, faults, f"journal.rollback-swap:{mod['rel']}") for mod in reversed(journal.get("modules_plan") or []): _restore_old_module(mod, faults) mod["status"] = "restored" _log(f"restored {mod['rel']}") save_journal(path, journal, faults, f"journal.restore:{mod['rel']}") for link in journal.get("links_plan") or []: if not link.get("created"): continue link_path = link["live_path"] if lstat_kind(link_path) == "symlink" and os.readlink(link_path) == link["target"]: faults.reach(f"rollback.unlink:{link['rel']}") os.unlink(link_path) fsync_dir(os.path.dirname(link_path)) link["created"] = False link["status"] = "removed" journal["state"] = "rolled_back" journal["rolled_back_at"] = now() journal["message"] = "rolled back" save_journal(path, journal, faults, "journal.rolled_back") except BaseException: try: _restore_published_baseline(journal) journal["state"] = "complete" journal["message"] = "rollback interrupted; published tree restored" save_journal(path, journal, FaultInjector(), "journal.rollback-restored") except BaseException as recovery_error: raise NeedsIntervention( f"rollback failed and the published tree could not be restored: {recovery_error}" ) from recovery_error raise message = f"run {run_id} rolled back; replacement kept at its retired path" _log(message) return _record_rollback(journal, faults, publisher, finished, started, now, message) finally: lock.release()
def _dry_run(spec, probes, faults): bwrap = probes.get("bwrap") or {} easybuild = probes.get("easybuild") or {} exchange = probes.get("rename_exchange") or {} lines = [ f"backend: {spec.backend}", f"software: {abspath(spec.software)}", f"modules: {abspath(spec.modules)}", f"state root: {abspath(spec.state_root)}", f"bwrap usable: {bool(bwrap.get('usable'))} ({bwrap.get('reason') or bwrap.get('version') or 'present'})", "user.max_user_namespaces: " + str((probes.get("user_namespaces") or {}).get("max_user_namespaces")), "renameat2 RENAME_EXCHANGE on " + str(exchange.get("fstype") or "this filesystem") + ": " + ("yes" if exchange.get("ok") else "no"), "easybuild: " + (easybuild.get("version_text") or easybuild.get("reason") or "not found"), "lock: local flock lock plus POSIX record lock; cross-node coherence unmeasured (open-004)", "publish: renameat2(RENAME_EXCHANGE), with a restoring ordinary-rename fallback", ] problems = [] if spec.backend == "bwrap" and not bwrap.get("usable"): problems.append(bwrap.get("reason") or "bwrap is not usable") rels = list(spec.module_rels) for rel in rels: path = os.path.join(spec.software, rel) if lstat_kind(path) == "symlink": problems.append(f"{path} is a symlink") ctx = _Ctx(spec, os.path.join(spec.state_root, "generations", "<run-id>"), faults) if spec.backend == "bwrap": cmd, _env = bwrap_command(ctx) elif spec.backend == "userns": cmd, _env, _software, _modules = userns_command(ctx) else: cmd, _env, _software, _modules = installpath_command(ctx) lines.append("command: " + format_command(cmd)) if problems: message = "dry-run would refuse: " + "; ".join(problems) _log(message) for line in lines: _log(line) return Result(2, None, "dry-run", message, format_command(cmd)) message = "dry-run: no build, no module file written" _log(message) for line in lines: _log(line) return Result(0, None, "dry-run", message, format_command(cmd))
[docs] def describe_module(path): kind = lstat_kind(path) if kind != "file": return {"path": os.fspath(path), "kind": kind, "complete": False, "root": None} text = read_bytes(path).decode("utf-8") syntax, root = parse_root(text) return { "path": os.fspath(path), "kind": "file", "syntax": syntax, "root": root, "root_kind": lstat_kind(root) if root else None, "complete": bool(root) and generation_complete(root), }