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:
+29
-16
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user