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:
+117
-1
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user