diff --git a/scripts/elway b/scripts/elway index 855939a..916a4a0 100755 --- a/scripts/elway +++ b/scripts/elway @@ -67,6 +67,7 @@ from __future__ import annotations import argparse import dataclasses +import datetime import getpass import json import os @@ -91,6 +92,89 @@ except ImportError: sys.exit(2) +# ─── Run logging ────────────────────────────────────────────────────────── +# +# Every run tees output to two files in addition to the terminal so the +# operator (or a sibling tool) can re-read what happened without scrolling +# back. Two locations: +# +# /tmp/elway-last.log — overwritten per run; the easy +# "what just happened" target +# ~/.cache/elway/runs/--.log +# — permanent timestamped record; +# accumulates over time +# +# ANSI color codes are kept in the file (modern less/tail handle them); +# strip with `sed 's/\x1b\[[0-9;]*m//g'` if pasting somewhere that doesn't. + +LAST_LOG_PATH = Path("/tmp/elway-last.log") +LOG_ROOT = Path.home() / ".cache" / "elway" / "runs" + + +class _Tee: + """File-like that fans writes out to one or more streams.""" + + def __init__(self, *streams) -> None: + self._streams = [s for s in streams if s is not None] + + def write(self, data) -> int: + for s in self._streams: + try: + s.write(data) + except (BrokenPipeError, ValueError): + pass + return len(data) + + def flush(self) -> None: + for s in self._streams: + try: + s.flush() + except (BrokenPipeError, ValueError): + pass + + def isatty(self) -> bool: + # Preserve color decisions: if the *first* stream (the real + # terminal) is a tty, the Tee reports as one. The on-disk files + # get the same colored output, which is fine. + return bool(self._streams) and getattr(self._streams[0], "isatty", lambda: False)() + + +def _open_run_logs(host: str, playbook: Optional[str], explicit: Optional[Path]) -> tuple[Optional[Path], list]: + """Open log files for this run. Returns (permanent_log_path, [open_handles]).""" + handles = [] + perm_path = None + + safe_host = re.sub(r"[^a-zA-Z0-9._-]", "_", host) + pb_label = "ad-hoc" if not playbook else re.sub(r"[^a-zA-Z0-9._-]", "_", Path(playbook).stem) + + if explicit is not None: + # Caller forced a specific path — only open that one (no /tmp tee). + try: + explicit.parent.mkdir(parents=True, exist_ok=True) + handles.append(open(explicit, "w", encoding="utf-8")) + perm_path = explicit + except OSError as exc: + sys.stderr.write(f"[elway] could not open --log {explicit}: {exc}\n") + return perm_path, handles + + # Default: tee both /tmp/elway-last.log and a timestamped permanent log. + try: + handles.append(open(LAST_LOG_PATH, "w", encoding="utf-8")) + except OSError as exc: + sys.stderr.write(f"[elway] could not open {LAST_LOG_PATH}: {exc}\n") + + try: + LOG_ROOT.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + perm_path = LOG_ROOT / f"{ts}-{safe_host}-{pb_label}.log" + handles.append(open(perm_path, "w", encoding="utf-8")) + except OSError as exc: + sys.stderr.write(f"[elway] could not open permanent log under {LOG_ROOT}: {exc}\n") + perm_path = None + + return perm_path, handles + + # ─── Terminal output helpers ────────────────────────────────────────────── IS_TTY = sys.stdout.isatty() and os.environ.get("NO_COLOR") != "1" @@ -680,6 +764,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: ) p.add_argument("--dry-run", action="store_true", help="Print the plan without executing.") p.add_argument("-v", "--verbose", action="store_true", help="Show ssh/scp invocations.") + p.add_argument( + "--log", + type=Path, + default=None, + help="Override log file path. Default: tee to /tmp/elway-last.log + ~/.cache/elway/runs/--.log", + ) + p.add_argument( + "--no-log", + action="store_true", + help="Don't write run logs to disk; terminal-only.", + ) return p.parse_args(argv) @@ -718,6 +813,16 @@ def main(argv: Optional[list[str]] = None) -> int: merged_vars = dict(pb.vars) merged_vars.update(parse_var_args(args.var)) + # Set up run logging — tee stdout to log file(s) so subsequent reads + # don't have to scroll the terminal. `--no-log` disables. + log_handles: list = [] + perm_log: Optional[Path] = None + real_stdout = sys.stdout + if not args.no_log: + perm_log, log_handles = _open_run_logs(args.host, args.playbook, args.log) + if log_handles: + sys.stdout = _Tee(real_stdout, *log_handles) + # ControlMaster socket in a temp dir scoped to this run. with tempfile.TemporaryDirectory(prefix="elway-") as tmp: control_path = os.path.join(tmp, "cm-%r@%h:%p") @@ -735,6 +840,8 @@ def main(argv: Optional[list[str]] = None) -> int: f"({len(pb.steps)} steps" + (f", {len(pb.verify)} verify" if pb.verify else "") + ")")) + if perm_log is not None: + print(DIM(f" log: {perm_log}")) step_results = run_phase( ctx, "steps", pb.steps, merged_vars, global_stop_on_fail=not args.continue_on_error, @@ -750,9 +857,18 @@ def main(argv: Optional[list[str]] = None) -> int: global_stop_on_fail=not args.continue_on_error, dry_run=args.dry_run, ) - return print_summary(step_results, verify_results) + rc = print_summary(step_results, verify_results) + if perm_log is not None: + print(DIM(f"log saved: {perm_log}")) + return rc finally: ctx.close_master() + sys.stdout = real_stdout + for h in log_handles: + try: + h.close() + except Exception: + pass if __name__ == "__main__":