Source code for eb_staged_rebuild.retire

"""Retire a module file whose package is published elsewhere.

``retire`` moves one module file out of the module tree and into
``<state-root>/retired/<timestamp>/<absolute path without the leading />``
with one same-filesystem ``os.replace``. The software directory the module
named is not touched. A job that already loaded the module keeps its paths. A
job that loads the module afterwards no longer finds it in this tree and falls
through to the next entry on MODULEPATH.

The record is a journal under ``<state-root>/runs/retire-<timestamp>/`` in the
same format as a run journal (``kind`` is ``retire`` and ``modules_plan`` is
empty). ``rollback RUN_ID`` puts the file back. ``gc`` reads the record to
decide when the software directory is ready to remove.
"""

import errno
import os
import time

from eb_staged_rebuild.atomicio import fsync_dir, read_bytes, sha256_bytes
from eb_staged_rebuild.errors import NeedsIntervention, Refused
from eb_staged_rebuild.faults import FaultInjector
from eb_staged_rebuild.journal import journal_path, list_journals, run_dir, 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

KIND = "retire"


[docs] def is_retire(journal): return journal.get("kind") == KIND
[docs] def retired_dir(state_root): return os.path.join(abspath(state_root), "retired")
def _stamp(now): return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(now)) def _log(message): print(f"eb-staged-rebuild: {message}", flush=True) def _already_retired(state_root, module): for journal in list_journals(state_root): if not is_retire(journal): continue if journal.get("module_path") == module and journal.get("state") in ("retiring", "retired"): return journal return None def _unique(state_root, now): stamp = _stamp(now) candidate = stamp count = 1 while os.path.lexists(os.path.join(retired_dir(state_root), candidate)) or os.path.lexists( run_dir(state_root, f"retire-{candidate}") ): count += 1 candidate = f"{stamp}-{count}" return candidate def _check_same_filesystem(module, destination_parent): if os.stat(os.path.dirname(module)).st_dev != os.stat(destination_parent).st_dev: raise Refused( f"{module} and the state root are on different filesystems; " "retire needs one atomic rename, so put --state-root on the same filesystem as the module tree" ) def _plan(state_root, module): module = abspath(module) if is_within(module, state_root): raise Refused(f"{module} is inside the state root") previous = _already_retired(state_root, module) if previous is not None: raise Refused(f"{module} is already retired ({previous['run_id']})") kind = lstat_kind(module) if kind != "file": raise Refused(f"{module} is not a regular file ({kind})") data = read_bytes(module) try: text = data.decode("utf-8") except UnicodeError as exc: raise Refused(f"{module} is not UTF-8 text") from exc _syntax, root = parse_root(text) if not root: raise Refused(f"{module} has no root assignment, so its software directory is unknown") return module, data, abspath(root)
[docs] def retire(state_root, module_file, reason=None, dry_run=False, faults=None, clock=None, lock_timeout=0): """Move ``module_file`` into the state root and journal it. Returns the journal.""" from eb_staged_rebuild.engine import Result state_root = abspath(state_root) faults = faults or FaultInjector() now = time.time if clock is None else clock if dry_run: module, _data, software_dir = _plan(state_root, module_file) message = f"would retire {module} (software directory {software_dir} is left in place)" _log(message) return Result(0, None, "dry-run", message) lock = FileLock(state_root) lock.acquire(lock_timeout) try: module, data, software_dir = _plan(state_root, module_file) when = now() name = _unique(state_root, when) run_id = f"retire-{name}" destination = os.path.join(retired_dir(state_root), name, module.lstrip(os.sep)) os.makedirs(os.path.dirname(destination), exist_ok=True) try: _check_same_filesystem(module, os.path.dirname(destination)) except Refused: _discard_empty(os.path.join(retired_dir(state_root), name)) raise path = journal_path(state_root, run_id) journal = { "schema": 1, "kind": KIND, "run_id": run_id, "state": "retiring", "state_root": state_root, "module_path": module, "module_hash": sha256_bytes(data), "software_dir": software_dir, "retired_path": destination, "reason": reason or "", "created_at": when, "updated_at": when, "retired_at": None, "rolled_back_at": None, "modules_plan": [], "links_plan": [], "message": "", } save_journal(path, journal, faults, "journal.retiring") _move(module, destination, faults, "retire") journal["state"] = "retired" journal["retired_at"] = now() journal["updated_at"] = journal["retired_at"] journal["message"] = "module file retired" save_journal(path, journal, faults, "journal.retired") message = f"retired {module} -> {destination}; software directory {software_dir} left in place" _log(message) return Result(0, run_id, "retired", message) finally: lock.release()
def _discard_empty(top): for current, _dirs, _files in os.walk(top, topdown=False): try: os.rmdir(current) except OSError: pass def _move(source, destination, faults, step): """Rename ``source`` onto ``destination`` after the file bytes are durable.""" faults.reach(f"{step}.fsync") fd = os.open(source, os.O_RDONLY) try: os.fsync(fd) finally: os.close(fd) faults.reach(f"{step}.rename") try: os.replace(source, destination) except OSError as exc: if exc.errno == errno.EXDEV: raise Refused(f"{source} and {destination} are on different filesystems") from exc raise fsync_dir(os.path.dirname(destination)) fsync_dir(os.path.dirname(source))
[docs] def rollback_retire(state_root, journal, faults, clock): """Put a retired module file back and journal it. Caller holds the lock.""" from eb_staged_rebuild.engine import Result run_id = journal["run_id"] path = journal_path(state_root, run_id) if journal["state"] == "rolled_back": message = f"run {run_id} is already rolled back" _log(message) return Result(0, run_id, "rolled_back", message) module = journal["module_path"] retired = journal["retired_path"] live_kind = lstat_kind(module) retired_kind = lstat_kind(retired) if retired_kind == "file": if sha256_bytes(read_bytes(retired)) != journal["module_hash"]: raise NeedsIntervention(f"{retired} does not match the module file recorded for {run_id}") if live_kind != "missing": raise NeedsIntervention(f"{module} exists again; refusing to overwrite it with the retired copy") _move(retired, module, faults, "rollback.retire") _log(f"restored {module}") elif live_kind == "file" and sha256_bytes(read_bytes(module)) == journal["module_hash"]: pass else: raise NeedsIntervention(f"neither {module} nor {retired} holds the retired module file") journal["state"] = "rolled_back" journal["rolled_back_at"] = clock() journal["updated_at"] = journal["rolled_back_at"] journal["message"] = "rolled back" save_journal(path, journal, faults, "journal.rolled_back") message = f"run {run_id} rolled back; {module} restored" _log(message) return Result(0, run_id, "rolled_back", message)