elway: lazy sudo probe — don't prompt when every sudo step will skip

Previously the startup logic ran `probe_sudo()` if any step in the
playbook declared `sudo: true`, regardless of whether that step's
creates:/when: gates would actually let it fire. The result on the
task-board deploy rerun was a spurious password prompt followed by
six SKIPPED lines — the prompt served no purpose.

New flow:
  - Remove the upfront probe in main().
  - SSHContext.sudo_password defaults to None; new sudo_probed flag
    tracks whether we've already prompted this session.
  - run_shell_step + run_upload_step call ensure_sudo(ctx) only at
    the point a sudo step is actually executing — i.e. after its
    skip conditions have been evaluated and passed. Idempotent:
    probes at most once per playbook run.

Tradeoff accepted: if the user fat-fingers the password, they see it
mid-run on the first sudo step rather than upfront. `stop_on_fail`
(default true) halts cleanly; they rerun. Lower friction for the
common idempotent-rerun case, same recoverability.

Verified against playbooks/deploy-task-board.yaml — prior run
prompted + completed in 1.7s; new run completes in 1.7s with no
prompt because every sudo step skip-gated.
This commit is contained in:
2026-04-24 15:04:36 -07:00
parent 9c20e42215
commit 42fbc4b4c4
+29 -16
View File
@@ -296,7 +296,8 @@ class SSHContext:
host: str
control_path: str
verbose: bool
sudo_password: Optional[str]
sudo_password: Optional[str] = None
sudo_probed: bool = False # so we probe at most once per run, lazily
def _base_ssh_opts(self) -> list[str]:
return [
@@ -329,8 +330,18 @@ class SSHContext:
pass
def probe_sudo(ctx: SSHContext) -> Optional[str]:
"""Return a sudo password if one is needed, else None. Prompts locally."""
def ensure_sudo(ctx: SSHContext) -> None:
"""Idempotent lazy sudo probe. Called before the first sudo step that will
actually run — not at startup — so playbooks whose sudo steps all skip
never prompt for a password.
Sets ctx.sudo_password to None if NOPASSWD sudo works, or to the validated
password the user entered. Marks ctx.sudo_probed=True so subsequent sudo
steps reuse the already-acquired credential.
"""
if ctx.sudo_probed:
return
ctx.sudo_probed = True
# Is sudo configured NOPASSWD for this user? `sudo -n -v` exits 0 if so.
p = subprocess.run(
ctx.ssh_cmd("sudo -n -v"),
@@ -338,7 +349,8 @@ def probe_sudo(ctx: SSHContext) -> Optional[str]:
stderr=subprocess.DEVNULL,
)
if p.returncode == 0:
return None
ctx.sudo_password = None
return
pw = getpass.getpass(f"[elway] sudo password for {ctx.host}: ")
# Validate it works; don't silently carry a wrong password through the whole run.
validation = subprocess.run(
@@ -349,8 +361,8 @@ def probe_sudo(ctx: SSHContext) -> Optional[str]:
stderr=subprocess.PIPE,
)
if validation.returncode != 0:
raise SystemExit("elway: sudo password validation failed; aborting before anything runs.")
return pw
raise SystemExit("elway: sudo password validation failed; aborting.")
ctx.sudo_password = pw
# ─── Step execution ────────────────────────────────────────────────────────
@@ -383,10 +395,11 @@ def run_shell_step(ctx: SSHContext, step: Step, prefix: str) -> int:
"""Run a shell step on the remote host. Returns exit code."""
cmd_inner = step.shell or ""
if step.sudo:
# bash -s eats stdin for script; sudo -S reads password from stdin.
# We prepend the password line ourselves and pipe cmd_inner via bash -c after.
# Using `sudo -S -p '' bash -c <script>` lets sudo consume its password line,
# then runs the script. The password arrives as one line on stdin.
# Lazy: probe (and prompt if needed) right before the first sudo step
# actually executes. Skipped sudo steps never trigger a prompt.
ensure_sudo(ctx)
# `sudo -S -p '' bash -c <script>` lets sudo consume its password
# line from stdin, then runs the script. Password comes as one line.
remote = "sudo -S -p '' bash -c " + shlex.quote(cmd_inner)
stdin_data = (ctx.sudo_password or "") + "\n"
else:
@@ -431,7 +444,8 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int:
)
return rc
# Sudo path: scp to /tmp as user, then sudo mv + chmod server-side.
# Sudo path: probe lazily, then scp to /tmp as user, then sudo mv + chmod server-side.
ensure_sudo(ctx)
staging = f"/tmp/elway.{os.getpid()}.{int(time.time() * 1000)}.{src.name}"
if ctx.verbose:
print(DIM(f"{prefix}$ scp {up.src} {ctx.host}:{staging}"))
@@ -704,8 +718,6 @@ def main(argv: Optional[list[str]] = None) -> int:
merged_vars = dict(pb.vars)
merged_vars.update(parse_var_args(args.var))
needs_sudo = any(s.sudo for s in pb.steps + pb.verify)
# 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")
@@ -713,10 +725,11 @@ def main(argv: Optional[list[str]] = None) -> int:
host=args.host,
control_path=control_path,
verbose=args.verbose,
sudo_password=None,
)
if needs_sudo and not args.dry_run:
ctx.sudo_password = probe_sudo(ctx)
# No upfront sudo probe — lazily triggered from run_shell_step /
# run_upload_step the first time a sudo step actually runs (i.e.
# after its when:/creates: skip conditions pass). Playbooks whose
# sudo steps all skip never prompt.
try:
print(BOLD(f"── {args.host} ─ playbook: {args.playbook or 'ad-hoc'} "
f"({len(pb.steps)} steps"