elway: tee every run to /tmp/elway-last.log + ~/.cache/elway/runs/

Avoids the "paste the full output" friction. Every elway run now
writes its full streamed output to two files in addition to the
terminal:

  /tmp/elway-last.log
      always overwritten — the easy "what just happened" target

  ~/.cache/elway/runs/<ts>-<host>-<playbook>.log
      timestamped permanent record; accumulates across runs

Implementation: small _Tee class wraps sys.stdout for the duration
of main() so all `print(...)` calls fan out to the real terminal +
both file handles. Subprocess output already goes through print()
via _stream_process, so the build/healthz/etc. text is captured.
ANSI color codes are kept in the file so colors are preserved in
log viewers that handle them (less -R, modern tail). Strip with
`sed 's/\x1b\[[0-9;]*m//g'` for paste-elsewhere.

New flags:
  --log <path>   override path; replaces both default destinations
  --no-log       terminal-only, skip both files

Path of the permanent log is printed at the top of every run so
you know where it landed without remembering the timestamp pattern.
This commit is contained in:
vh
2026-04-24 17:02:26 -07:00
parent 7875382aed
commit 4f7bf3b0b6
+117 -1
View File
@@ -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/<ts>-<host>-<playbook>.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/<ts>-<host>-<playbook>.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__":