Source code for eb_staged_rebuild.cli

"""Command line for eb-staged-rebuild."""

import argparse
import json
import os
import sys

from eb_staged_rebuild import __version__
from eb_staged_rebuild.constants import DEFAULT_GRACE_HOURS
from eb_staged_rebuild.engine import Spec, describe_module, resume, rollback, run
from eb_staged_rebuild.errors import NeedsIntervention, Refused, StagedRebuildError
from eb_staged_rebuild.gc import collect
from eb_staged_rebuild.journal import list_journals
from eb_staged_rebuild.retire import retire
from eb_staged_rebuild.probe import probe_all


def _software(value):
    return value or os.environ.get("EASYBUILD_INSTALLPATH_SOFTWARE")


def _modules(value):
    return value or os.environ.get("EASYBUILD_INSTALLPATH_MODULES")


def _state_root(value, software):
    if value:
        return value
    env = os.environ.get("EB_STAGED_REBUILD_STATE_ROOT")
    if env:
        return env
    if not software:
        return None
    return os.path.join(os.path.dirname(os.path.abspath(software)), ".eb-staged-rebuild")


def _grace(value):
    if value is not None:
        return value
    return float(
        os.environ.get(
            "EB_STAGED_REBUILD_GRACE_HOURS",
            str(DEFAULT_GRACE_HOURS),
        )
    )


def _add_common(parser):
    parser.add_argument("--software", help="live EASYBUILD_INSTALLPATH_SOFTWARE")
    parser.add_argument("--modules", help="live EASYBUILD_INSTALLPATH_MODULES")
    parser.add_argument("--state-root", help="journal and stage root (default: <parent of software>/.eb-staged-rebuild)")
    parser.add_argument("--eb", default=os.environ.get("EB_STAGED_REBUILD_EB", "eb"), help="EasyBuild executable")


def _parser():
    parser = argparse.ArgumentParser(
        prog="eb-staged-rebuild",
        description=(
            "Build EasyBuild software in a staged tree, then exchange the complete "
            "tree with the stable live directory."
        ),
    )
    parser.add_argument("--version", action="version", version=f"eb-staged-rebuild {__version__}")
    sub = parser.add_subparsers(dest="command", required=True)

    probe = sub.add_parser("probe", help="report bwrap, user namespaces, RENAME_EXCHANGE, and EasyBuild")
    probe.add_argument("--path", help="directory on the filesystem to probe (default: .)")
    probe.add_argument("--eb", default=os.environ.get("EB_STAGED_REBUILD_EB", "eb"))
    probe.add_argument("--json", action="store_true")

    run_p = sub.add_parser("run", help="build and atomically swap a staged tree")
    _add_common(run_p)
    run_p.add_argument(
        "--backend",
        choices=("auto", "bwrap", "userns", "installpath"),
        default="auto",
        help=(
            "build isolation: auto tries bwrap, then a read-only user namespace, "
            "then the unsandboxed install-path fallback"
        ),
    )
    run_p.add_argument("--grace-hours", type=float, default=None)
    run_p.add_argument("--dry-run", action="store_true")
    run_p.add_argument("--module", action="append", default=[], dest="module_rels", metavar="NAME/VERSION")
    run_p.add_argument("--run-id", default=None)
    run_p.add_argument("--lock-timeout", type=float, default=None)
    run_p.add_argument("--skip-live-check", action="store_true")
    run_p.add_argument(
        "--prepare",
        help="executable called with a directory-swap context JSON before the live directory is exchanged",
    )
    run_p.add_argument(
        "--ledger-config",
        help="JSON eb-stack ledger configuration; started records precede the directory swap",
    )
    run_p.add_argument("eb_args", nargs=argparse.REMAINDER, help="easyconfig and eb options, after --")

    resume_p = sub.add_parser("resume", help="continue a run after a crash")
    resume_p.add_argument("--state-root", required=True)
    resume_p.add_argument("--lock-timeout", type=float, default=None)
    resume_p.add_argument("run_id")

    back_p = sub.add_parser("rollback", help="restore the previous module files, or put a retired module file back")
    back_p.add_argument("--state-root", required=True)
    back_p.add_argument("--lock-timeout", type=float, default=None)
    back_p.add_argument("run_id")

    gc_p = sub.add_parser("gc", help="remove hidden retired siblings after the grace period")
    gc_p.add_argument("--state-root", required=True)
    gc_p.add_argument("--grace-hours", type=float, default=None)
    gc_p.add_argument("--dry-run", action="store_true")
    gc_p.add_argument("--lock-timeout", type=float, default=None)
    gc_p.add_argument(
        "--include-failed",
        action="store_true",
        help="also remove failed runs that published nothing and are older than the grace period",
    )

    retire_p = sub.add_parser(
        "retire",
        help="move a module file out of the module tree after its package is published elsewhere",
    )
    retire_p.add_argument("--state-root", required=True)
    retire_p.add_argument("--module-file", required=True, help="module file to retire")
    retire_p.add_argument("--reason", default=None)
    retire_p.add_argument("--dry-run", action="store_true")
    retire_p.add_argument("--lock-timeout", type=float, default=0.0, help="seconds to wait for the lock (default: refuse at once)")

    status_p = sub.add_parser("status", help="show run journals")
    status_p.add_argument("--state-root", required=True)
    status_p.add_argument("run_id", nargs="?")

    resolve_p = sub.add_parser("resolve", help="print the install path a module file names")
    resolve_p.add_argument("module")
    return parser


def _eb_args(raw):
    args = list(raw)
    if args and args[0] == "--":
        args = args[1:]
    if not any(not arg.startswith("-") for arg in args):
        raise Refused("pass the easyconfig after --, for example: eb-staged-rebuild run --software S --modules M -- file.eb")
    return args


[docs] def main(argv=None): parser = _parser() args = parser.parse_args(argv) try: code = _dispatch(args) except (Refused, NeedsIntervention, StagedRebuildError) as exc: print(f"eb-staged-rebuild: {exc}", file=sys.stderr) return 2 except OSError as exc: print(f"eb-staged-rebuild: {exc}", file=sys.stderr) return 2 return code
def _dispatch(args): if args.command == "probe": directory = args.path or os.getcwd() report = probe_all(directory, args.eb) if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: _print_probe(report) return 0 if args.command == "resolve": described = describe_module(args.module) print(json.dumps(described, indent=2, sort_keys=True)) if described.get("kind") != "file" or not described.get("complete"): return 1 return 0 if args.command == "run": software = _software(args.software) modules = _modules(args.modules) if not software or not modules: raise Refused("--software and --modules (or EASYBUILD_INSTALLPATH_SOFTWARE and EASYBUILD_INSTALLPATH_MODULES) are required") spec = Spec( software=software, modules=modules, state_root=_state_root(args.state_root, software), eb_args=_eb_args(args.eb_args), backend=args.backend, grace_hours=_grace(args.grace_hours), dry_run=args.dry_run, module_rels=list(args.module_rels), eb_executable=args.eb, run_id=args.run_id, skip_live_check=args.skip_live_check, lock_timeout=args.lock_timeout, prepare_executable=args.prepare, ledger_config=args.ledger_config, ) if args.prepare is not None and not args.prepare: raise Refused("--prepare must name a program; it is empty") result = run(spec) return result.returncode if args.command == "resume": result = resume(args.state_root, args.run_id, lock_timeout=args.lock_timeout) return result.returncode if args.command == "rollback": result = rollback(args.state_root, args.run_id, lock_timeout=args.lock_timeout) return result.returncode if args.command == "gc": actions = collect( args.state_root, _grace(args.grace_hours), __import__("time").time(), dry_run=args.dry_run, include_failed=args.include_failed, lock_timeout=args.lock_timeout, ) for action in actions: print(f"{action['action']}: {action['path']} ({action['reason']})") return 0 if args.command == "retire": result = retire( args.state_root, args.module_file, reason=args.reason, dry_run=args.dry_run, lock_timeout=args.lock_timeout, ) return result.returncode if args.command == "status": journals = list_journals(args.state_root) if args.run_id: journals = [journal for journal in journals if journal.get("run_id") == args.run_id] if not journals: raise Refused(f"no journal for {args.run_id}") if not journals: print("no runs") return 0 for journal in journals: if journal.get("kind") == "retire": print(f"{journal.get('run_id')} {journal.get('state')} retire {journal.get('module_path')}") print(f" software directory {journal.get('software_dir')}") continue modules = journal.get("modules_plan") or [] published = sum(1 for mod in modules if mod.get("status") == "published") print( f"{journal.get('run_id')} {journal.get('state')} " f"backend={journal.get('backend')} modules={published}/{len(modules)}" ) if journal.get("message"): print(f" {journal['message']}") for mod in modules: print( f" {mod.get('status'):<10} {mod.get('rel')} " f"live={mod.get('live_software')} retired={mod.get('retired')}" ) return 0 raise Refused(f"unknown command {args.command}") def _print_probe(report): bwrap = report["bwrap"] namespaces = report["user_namespaces"] userns = report["userns_sandbox"] exchange = report["rename_exchange"] easybuild = report["easybuild"] filesystem = report.get("filesystem") or {} print(f"bwrap: {'usable' if bwrap.get('usable') else 'not usable'}") if bwrap.get("version"): print(f" version: {bwrap['version']}") if bwrap.get("reason"): print(f" reason: {bwrap['reason']}") print(f"user.max_user_namespaces: {namespaces.get('max_user_namespaces')}") if namespaces.get("unprivileged_userns_clone") is not None: print(f"kernel.unprivileged_userns_clone: {namespaces['unprivileged_userns_clone']}") print(f"user-namespace sandbox: {'usable' if userns.get('usable') else 'not usable'}") if userns.get("reason"): print(f" reason: {userns['reason']}") fstype = exchange.get("fstype") or filesystem.get("fstype") print( "RENAME_EXCHANGE: " + ("yes" if exchange.get("ok") else "no") + (f" on {fstype}" if fstype else "") ) if exchange.get("error"): print(f" {exchange['error']}") if easybuild.get("found"): version = easybuild.get("framework_version") rendered = ".".join(str(part) for part in version) if version else "unknown" print(f"EasyBuild framework: {rendered}") print(f" bwrap stage-path keys: {easybuild.get('stage_path_keys')} ({easybuild.get('keys_source')})") else: print(f"EasyBuild: {easybuild.get('reason')}") print(f"filesystem: {filesystem.get('fstype')} {filesystem.get('mount') or ''}".rstrip()) print("lock: local flock lock plus POSIX record lock; cross-node coherence unmeasured (open-004)") if __name__ == "__main__": raise SystemExit(main())