Source code for eb_staged_rebuild.gc

"""Remove hidden trees retired by successful swaps or explicit rollbacks.

The clock starts at publish or rollback.  The default grace period is the
unmeasured value carried by ns-051; operators must replace it with the site's
longest job plus a safety margin.  Cluster-wide ``lsof`` is not used.

A module file moved aside by ``retire`` starts that same clock for the stable
software directory the module named. gc reports that directory. It does not
delete it: the directory is the path jobs already have open, and gc only
removes a hidden sibling recorded by a directory swap.
"""

import os
import stat
import time

from eb_staged_rebuild.atomicio import read_bytes
from eb_staged_rebuild.cleanup import remove_generation_tree
from eb_staged_rebuild.constants import SECONDS_PER_HOUR
from eb_staged_rebuild.errors import Refused
from eb_staged_rebuild.journal import journal_path, list_journals, save_journal
from eb_staged_rebuild.lockfile import FileLock
from eb_staged_rebuild.modulefile import parse_root
from eb_staged_rebuild.paths import abspath, is_within, lstat_kind
from eb_staged_rebuild.retire import is_retire


def _retirement(journal, mod, include_failed):
    state = journal.get("state")
    if state == "complete":
        return mod.get("published_at"), "previous live tree"
    if state == "rolled_back":
        return journal.get("rolled_back_at"), "rolled-back candidate"
    if include_failed and state == "failed" and mod.get("status") == "pending":
        return journal.get("updated_at") or journal.get("created_at"), "failed candidate"
    return None, f"state {state} is kept"


def _safe_retired_path(mod):
    live = abspath(mod.get("live_software") or "")
    retired = abspath(mod.get("retired") or "")
    if not live or not retired:
        raise Refused("journal has no live/retired software path")
    if live == retired or os.path.dirname(live) != os.path.dirname(retired):
        raise Refused(f"refusing retired path that is not a live sibling: {retired}")
    expected_prefix = f".{os.path.basename(live)}."
    if not os.path.basename(retired).startswith(expected_prefix):
        raise Refused(f"refusing unexpected retired path name: {retired}")
    return retired


def _module_root(path):
    """Return the software directory a tracked module names, or None if it is absent.

    An unreadable, non-regular, or rootless module is a refusal. Guessing that
    nothing refers to a tree is how a collector deletes a directory that is
    still published.
    """
    try:
        mode = os.lstat(path).st_mode
    except FileNotFoundError:
        return None
    except OSError as error:
        raise Refused(f"cannot inspect live module {path}: {error}") from error
    if not stat.S_ISREG(mode):
        raise Refused(f"cannot determine live references from a non-regular module: {path}")
    try:
        _syntax, root = parse_root(read_bytes(path).decode("utf-8"))
    except (OSError, UnicodeError) as error:
        raise Refused(f"cannot read live references from {path}: {error}") from error
    if not root:
        raise Refused(f"cannot determine the live software root from {path}")
    return abspath(root)


def _resolved(path):
    try:
        return os.path.realpath(path, strict=True)
    except OSError as error:
        raise Refused(f"cannot resolve live software references from {path}: {error}") from error


def _live_roots(journals):
    roots = set()
    for journal in journals:
        if is_retire(journal):
            # A retire that is not complete, or that was rolled back, leaves the
            # module file live at its original path.
            if journal.get("state") != "retired":
                root = _module_root(journal["module_path"])
                if root:
                    roots.add(root)
                    roots.add(_resolved(root))
            continue
        for mod in journal.get("modules_plan") or []:
            live = mod.get("live_module")
            if not live:
                continue
            root = _module_root(live)
            if root:
                roots.add(root)
                roots.add(_resolved(root))
    return roots


def _referenced(directory, roots):
    spellings = {abspath(directory), _resolved(directory)}
    return any(is_within(root, spelling) for root in roots for spelling in spellings)


def _iso(when):
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(when))


def _retired_software(journals, roots, grace_hours, now):
    """Report stable software directories named by retired module files.

    gc never deletes these. A hidden previous tree from a directory swap is
    removed by the caller. This entry is ``keep`` while a tracked module still
    resolves inside the directory or the grace period runs, then ``manual``.
    """
    latest = {}
    for journal in journals:
        if not is_retire(journal) or journal.get("state") != "retired":
            continue
        directory = abspath(journal["software_dir"])
        when = journal.get("retired_at")
        if when is None:
            continue
        latest[directory] = max(latest.get(directory, when), when)
    actions = []
    for directory in sorted(latest):
        if not os.path.isdir(directory):
            continue
        ready = latest[directory] + grace_hours * SECONDS_PER_HOUR
        if _referenced(directory, roots):
            actions.append({"path": directory, "action": "keep", "reason": "a live module resolves inside it"})
        elif now < ready:
            actions.append(
                {
                    "path": directory,
                    "action": "keep",
                    "reason": f"within grace (retired module); ready to remove by hand after {_iso(ready)}",
                    "retired_at": latest[directory],
                }
            )
        else:
            actions.append(
                {
                    "path": directory,
                    "action": "manual",
                    "reason": (
                        f"ready to remove by hand after {_iso(ready)}; "
                        "gc does not delete the stable software directory"
                    ),
                    "retired_at": latest[directory],
                }
            )
    return actions


def _collect_locked(state_root, grace_hours, now, dry_run, include_failed):
    state_root = abspath(state_root)
    journals = list_journals(state_root)
    roots = _live_roots(journals)
    actions = []
    for journal in journals:
        if is_retire(journal):
            continue
        changed = False
        for mod in journal.get("modules_plan") or []:
            if mod.get("retired_removed_at"):
                continue
            retired = _safe_retired_path(mod)
            if lstat_kind(retired) != "dir":
                continue
            when, why = _retirement(journal, mod, include_failed)
            if when is None:
                actions.append({"path": retired, "action": "keep", "reason": why})
                continue
            age = now - when
            if _referenced(retired, roots):
                actions.append(
                    {
                        "path": retired,
                        "action": "keep",
                        "reason": "a live module resolves inside it",
                    }
                )
                continue
            if age < grace_hours * SECONDS_PER_HOUR:
                actions.append(
                    {
                        "path": retired,
                        "action": "keep",
                        "reason": f"within grace ({why})",
                        "retired_at": when,
                    }
                )
                continue
            actions.append(
                {
                    "path": retired,
                    "action": "remove",
                    "reason": why,
                    "retired_at": when,
                }
            )
            if dry_run:
                continue
            remove_generation_tree(retired, os.path.dirname(retired))
            mod["retired_removed_at"] = now
            changed = True
        if changed:
            journal["message"] = "retired tree removed"
            save_journal(
                journal_path(state_root, journal["run_id"]),
                journal,
                None,
                "journal.gc",
            )
    actions.extend(_retired_software(journals, roots, grace_hours, now))
    return actions


[docs] def collect( state_root, grace_hours, now, dry_run=False, include_failed=False, lock_timeout=None, ): """Collect retired trees while holding the same lock as publish/rollback.""" lock = FileLock(state_root) lock.acquire(lock_timeout) try: return _collect_locked( state_root, grace_hours, now, dry_run, include_failed, ) finally: lock.release()