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
|
host: str
|
||||||
control_path: str
|
control_path: str
|
||||||
verbose: bool
|
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]:
|
def _base_ssh_opts(self) -> list[str]:
|
||||||
return [
|
return [
|
||||||
@@ -329,8 +330,18 @@ class SSHContext:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def probe_sudo(ctx: SSHContext) -> Optional[str]:
|
def ensure_sudo(ctx: SSHContext) -> None:
|
||||||
"""Return a sudo password if one is needed, else None. Prompts locally."""
|
"""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.
|
# Is sudo configured NOPASSWD for this user? `sudo -n -v` exits 0 if so.
|
||||||
p = subprocess.run(
|
p = subprocess.run(
|
||||||
ctx.ssh_cmd("sudo -n -v"),
|
ctx.ssh_cmd("sudo -n -v"),
|
||||||
@@ -338,7 +349,8 @@ def probe_sudo(ctx: SSHContext) -> Optional[str]:
|
|||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if p.returncode == 0:
|
if p.returncode == 0:
|
||||||
return None
|
ctx.sudo_password = None
|
||||||
|
return
|
||||||
pw = getpass.getpass(f"[elway] sudo password for {ctx.host}: ")
|
pw = getpass.getpass(f"[elway] sudo password for {ctx.host}: ")
|
||||||
# Validate it works; don't silently carry a wrong password through the whole run.
|
# Validate it works; don't silently carry a wrong password through the whole run.
|
||||||
validation = subprocess.run(
|
validation = subprocess.run(
|
||||||
@@ -349,8 +361,8 @@ def probe_sudo(ctx: SSHContext) -> Optional[str]:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
if validation.returncode != 0:
|
if validation.returncode != 0:
|
||||||
raise SystemExit("elway: sudo password validation failed; aborting before anything runs.")
|
raise SystemExit("elway: sudo password validation failed; aborting.")
|
||||||
return pw
|
ctx.sudo_password = pw
|
||||||
|
|
||||||
|
|
||||||
# ─── Step execution ────────────────────────────────────────────────────────
|
# ─── 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."""
|
"""Run a shell step on the remote host. Returns exit code."""
|
||||||
cmd_inner = step.shell or ""
|
cmd_inner = step.shell or ""
|
||||||
if step.sudo:
|
if step.sudo:
|
||||||
# bash -s eats stdin for script; sudo -S reads password from stdin.
|
# Lazy: probe (and prompt if needed) right before the first sudo step
|
||||||
# We prepend the password line ourselves and pipe cmd_inner via bash -c after.
|
# actually executes. Skipped sudo steps never trigger a prompt.
|
||||||
# Using `sudo -S -p '' bash -c <script>` lets sudo consume its password line,
|
ensure_sudo(ctx)
|
||||||
# then runs the script. The password arrives as one line on stdin.
|
# `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)
|
remote = "sudo -S -p '' bash -c " + shlex.quote(cmd_inner)
|
||||||
stdin_data = (ctx.sudo_password or "") + "\n"
|
stdin_data = (ctx.sudo_password or "") + "\n"
|
||||||
else:
|
else:
|
||||||
@@ -431,7 +444,8 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int:
|
|||||||
)
|
)
|
||||||
return rc
|
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}"
|
staging = f"/tmp/elway.{os.getpid()}.{int(time.time() * 1000)}.{src.name}"
|
||||||
if ctx.verbose:
|
if ctx.verbose:
|
||||||
print(DIM(f"{prefix}$ scp {up.src} {ctx.host}:{staging}"))
|
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 = dict(pb.vars)
|
||||||
merged_vars.update(parse_var_args(args.var))
|
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.
|
# ControlMaster socket in a temp dir scoped to this run.
|
||||||
with tempfile.TemporaryDirectory(prefix="elway-") as tmp:
|
with tempfile.TemporaryDirectory(prefix="elway-") as tmp:
|
||||||
control_path = os.path.join(tmp, "cm-%r@%h:%p")
|
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,
|
host=args.host,
|
||||||
control_path=control_path,
|
control_path=control_path,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
sudo_password=None,
|
|
||||||
)
|
)
|
||||||
if needs_sudo and not args.dry_run:
|
# No upfront sudo probe — lazily triggered from run_shell_step /
|
||||||
ctx.sudo_password = probe_sudo(ctx)
|
# 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:
|
try:
|
||||||
print(BOLD(f"── {args.host} ─ playbook: {args.playbook or 'ad-hoc'} "
|
print(BOLD(f"── {args.host} ─ playbook: {args.playbook or 'ad-hoc'} "
|
||||||
f"({len(pb.steps)} steps"
|
f"({len(pb.steps)} steps"
|
||||||
|
|||||||
Reference in New Issue
Block a user