diff --git a/playbooks/elway-smoke.yaml b/playbooks/elway-smoke.yaml index 73ef356..69eb317 100644 --- a/playbooks/elway-smoke.yaml +++ b/playbooks/elway-smoke.yaml @@ -1,20 +1,29 @@ -# elway's own smoke-test playbook. Exercises: vars (inline + CLI override), -# multi-step flow, upload with mode, stop-on-error semantics, verify phase. -# Target: any Linux host reachable over ssh that has /tmp and curl. +# elway's own smoke-test playbook. Exercises: +# - vars (inline default + CLI override) +# - multi-step flow +# - upload with mode +# - tier 1 idempotency: `creates:` skip +# - tier 2 idempotency: `changed_when:` to report ok vs changed +# - verify phase that tolerates already-set-up state +# +# Rerunnable: second run should show mostly `ok`/`skipped`, proving +# idempotency is wired correctly. vars: scratch_dir: /tmp/elway-smoke greeting: hello from elway steps: - - name: Clean any prior scratch dir - shell: rm -rf {{ scratch_dir }} - - name: Create scratch dir shell: mkdir -p {{ scratch_dir }} + creates: "{{ scratch_dir }}" - name: Drop a greeting file shell: echo "{{ greeting }}" > {{ scratch_dir }}/greeting.txt + # Skip the echo entirely if the file already contains exactly this line. + # `when:` gates BEFORE running; `changed_when:` decides ok-vs-changed AFTER. + # For "is the new state already the desired state?", `when:` is correct. + when: "! grep -qxF '{{ greeting }}' {{ scratch_dir }}/greeting.txt 2>/dev/null" - name: Upload this playbook into the scratch dir upload: @@ -22,12 +31,19 @@ steps: dest: "{{ scratch_dir }}/uploaded.yaml" mode: "0644" + - name: Record that we ran (a step with no idempotency hooks — always `changed`) + shell: date -Iseconds > {{ scratch_dir }}/last-run.txt + verify: - name: Greeting file has the expected content - shell: grep -q "{{ greeting }}" {{ scratch_dir }}/greeting.txt + shell: grep -qxF "{{ greeting }}" {{ scratch_dir }}/greeting.txt + # Verify steps don't actually "change" anything — they only attest. + changed_when: "false" - - name: Uploaded file is a non-empty yaml - shell: test -s {{ scratch_dir }}/uploaded.yaml && head -1 {{ scratch_dir }}/uploaded.yaml + - name: Uploaded yaml is non-empty + shell: test -s {{ scratch_dir }}/uploaded.yaml + changed_when: "false" - - name: Scratch dir exists and is listable - shell: ls -la {{ scratch_dir }} + - name: last-run.txt exists + shell: test -s {{ scratch_dir }}/last-run.txt + changed_when: "false" diff --git a/scripts/elway b/scripts/elway index 88fd133..3d661c7 100755 --- a/scripts/elway +++ b/scripts/elway @@ -37,13 +37,29 @@ Playbook schema (YAML): mode: "0644" # optional sudo: false # default false stop_on_fail: true # optional per-step override of global flag + # Idempotency (tier 1): if ANY of these conditions says 'skip', + # the step is marked `skipped`, not run, not counted as failure. + when: "" # skip unless expr exits 0 + creates: # skip if this path already exists + removes: # skip if this path is already absent + # Idempotency (tier 2): runs on the remote AFTER a successful step. + # If exit 0, step is `changed`; exit != 0, `ok` (meaning no-op). + # Without this, successful steps default to `changed` (Ansible's + # default for shell/command modules). + changed_when: "" verify: # optional; always runs after steps (even on fail) - name: Port responds shell: curl -sf http://localhost:8188/ >/dev/null -Exit codes: - 0 — every step (+ verify) passed or was ignored - 1 — a non-ignored step failed +Per-step state machine: + ok — ran successfully, changed_when reported no change + changed — ran successfully (default for shell/upload without changed_when) + skipped — conditions said skip, OR halted after a prior failure, OR dry-run + failed — exit code != 0 + +Overall outcome / exit code: + 0 if OK (all ok) or CHANGED (some changed, none failed) + 1 if FAILED (any failed step, non-ignored) 2 — usage / config error before execution began """ @@ -109,6 +125,19 @@ class Step: upload: Optional[UploadSpec] = None sudo: bool = False stop_on_fail: Optional[bool] = None # None = inherit global + # Tier 1 — conditional skip. Any of these evaluating to "skip" means the + # step is not run; it counts as `skipped`, not `failed`. + # when: remote shell expr; skip if exit != 0 (run when it returns 0) + # creates: remote path; skip if it already exists + # removes: remote path; skip if it does NOT exist + when: Optional[str] = None + creates: Optional[str] = None + removes: Optional[str] = None + # Tier 2 — change detection. Evaluated on the remote after a successful + # run; exit 0 means "something changed", exit != 0 means "no change". + # `changed_when: "false"` forces ok; `changed_when: "true"` forces changed. + # Without this field, a successful step is `changed` by default. + changed_when: Optional[str] = None def kind(self) -> str: return "shell" if self.shell is not None else "upload" @@ -121,23 +150,29 @@ class Playbook: verify: list[Step] +# Step result state machine: +# ok — ran successfully, changed_when said "no change" +# changed — ran successfully (default when changed_when not set) +# failed — exit code != 0 (and stop_on_fail / effective flag engaged) +# skipped — skipped due to when/creates/removes OR halted after prior fail +STATES = ("ok", "changed", "failed", "skipped") + + @dataclasses.dataclass class StepResult: step: Step phase: str # "steps" or "verify" index: int total: int - exit_code: int - duration_s: float - ignored: bool = False + state: str # one of STATES + exit_code: int = 0 + duration_s: float = 0.0 + skip_reason: Optional[str] = None @property - def passed(self) -> bool: - return self.exit_code == 0 - - @property - def effectively_passed(self) -> bool: - return self.passed or self.ignored + def succeeded(self) -> bool: + # "succeeded" = didn't fail. Skipped and ok and changed all count. + return self.state != "failed" # ─── Template substitution ───────────────────────────────────────────────── @@ -169,6 +204,10 @@ def substitute_step(step: Step, vars: dict, where: str) -> Step: dest=substitute(step.upload.dest, vars, f"{where} upload.dest"), mode=step.upload.mode, ) + for field in ("when", "creates", "removes", "changed_when"): + val = getattr(step, field) + if val is not None: + setattr(new, field, substitute(val, vars, f"{where} {field}")) return new @@ -207,12 +246,20 @@ def _load_step(raw: dict, where: str) -> Step: dest=_as_str(u["dest"], f"{where} upload.dest"), mode=str(mode) if mode is not None else None, ) + def opt_str(field: str) -> Optional[str]: + v = raw.get(field) + return None if v is None else _as_str(v, f"{where} {field}") + return Step( name=_as_str(raw["name"], f"{where} name"), shell=shell, upload=upload, sudo=bool(raw.get("sudo", False)), stop_on_fail=raw.get("stop_on_fail"), + when=opt_str("when"), + creates=opt_str("creates"), + removes=opt_str("removes"), + changed_when=opt_str("changed_when"), ) @@ -406,9 +453,70 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int: ) +# ─── Skip + change evaluators (tier 1 + tier 2) ──────────────────────────── + + +def _quiet_rc(ctx: SSHContext, remote_cmd: str) -> int: + """Run a shell expression on the remote under bash -c, discarding output.""" + # Wrapping in bash -c gives `!`, `[[`, pipes, etc. consistent semantics + # across hosts whose default login shell might be dash (Debian default) or + # something else weird. + wrapped = "bash -c " + shlex.quote(remote_cmd) + return subprocess.run( + ctx.ssh_cmd(wrapped), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + + +def evaluate_skip(ctx: SSHContext, step: Step) -> Optional[str]: + """If any pre-condition says skip, return a human-readable reason. Else None.""" + if step.when is not None: + rc = _quiet_rc(ctx, step.when) + if rc != 0: + return f"when: expr exited {rc} (needed 0 to run)" + if step.creates is not None: + rc = _quiet_rc(ctx, f"test -e {shlex.quote(step.creates)}") + if rc == 0: + return f"creates: {step.creates} already exists" + if step.removes is not None: + rc = _quiet_rc(ctx, f"test -e {shlex.quote(step.removes)}") + if rc != 0: + return f"removes: {step.removes} is already absent" + return None + + +def evaluate_changed(ctx: SSHContext, step: Step) -> bool: + """Post-step: should this count as `changed`? Default True (Ansible shell default).""" + if step.changed_when is None: + return True + rc = _quiet_rc(ctx, step.changed_when) + # exit 0 = the change-detector expression "fired" = step counts as changed. + return rc == 0 + + # ─── Driver / reporting ──────────────────────────────────────────────────── +STATE_BADGE = { + "ok": lambda: GREEN("○ OK"), + "changed": lambda: YELLOW("● CHANGED"), + "failed": lambda: RED("✗ FAILED"), + "skipped": lambda: DIM("⏭ SKIPPED"), +} + + +def _print_status(state: str, exit_code: int, elapsed: float, skip_reason: Optional[str]) -> None: + badge = STATE_BADGE[state]() + suffix = "" + if state == "failed": + suffix = f" (rc={exit_code})" + elif state == "skipped" and skip_reason: + suffix = f" — {skip_reason}" + tail = f" {DIM(f'{elapsed:.2f}s')}" if state not in ("skipped",) else "" + print(f" {badge}{suffix}{tail}") + + def run_phase( ctx: SSHContext, phase: str, @@ -427,10 +535,9 @@ def run_phase( print(header) if halted: - print(f" {DIM('SKIPPED (earlier failure)')}") - results.append( - StepResult(resolved, phase, i, len(steps), exit_code=0, duration_s=0.0, ignored=True) - ) + _print_status("skipped", 0, 0.0, "earlier step failed") + results.append(StepResult(resolved, phase, i, len(steps), + state="skipped", skip_reason="earlier step failed")) continue if dry_run: @@ -440,11 +547,25 @@ def run_phase( u = resolved.upload mode = f" mode={u.mode}" if u.mode else "" print(f" {DIM('dry-run:')} upload: {u.src} → {u.dest}{mode}") - results.append( - StepResult(resolved, phase, i, len(steps), exit_code=0, duration_s=0.0, ignored=True) - ) + if resolved.when or resolved.creates or resolved.removes: + conds = [] + if resolved.when: conds.append(f"when={resolved.when!r}") + if resolved.creates: conds.append(f"creates={resolved.creates!r}") + if resolved.removes: conds.append(f"removes={resolved.removes!r}") + print(f" {DIM('dry-run:')} conditions: {', '.join(conds)}") + results.append(StepResult(resolved, phase, i, len(steps), state="skipped", + skip_reason="dry-run")) continue + # Tier 1: conditional skip + skip_reason = evaluate_skip(ctx, resolved) + if skip_reason is not None: + _print_status("skipped", 0, 0.0, skip_reason) + results.append(StepResult(resolved, phase, i, len(steps), + state="skipped", skip_reason=skip_reason)) + continue + + # Execute prefix = f" {DIM(f'[{phase[0]}{i}]')} " t0 = time.monotonic() if resolved.kind() == "shell": @@ -453,15 +574,17 @@ def run_phase( rc = run_upload_step(ctx, resolved, prefix) elapsed = time.monotonic() - t0 - if rc == 0: - status = GREEN("✓ PASS") - else: - status = RED(f"✗ FAIL (rc={rc})") - - print(f" {status} {DIM(f'{elapsed:.2f}s')}") - results.append(StepResult(resolved, phase, i, len(steps), exit_code=rc, duration_s=elapsed)) - if rc != 0: + state = "failed" + else: + # Tier 2: changed_when decides ok vs changed + state = "changed" if evaluate_changed(ctx, resolved) else "ok" + + _print_status(state, rc, elapsed, None) + results.append(StepResult(resolved, phase, i, len(steps), + state=state, exit_code=rc, duration_s=elapsed)) + + if state == "failed": step_stop = resolved.stop_on_fail effective_stop = global_stop_on_fail if step_stop is None else step_stop if effective_stop and phase == "steps": @@ -470,29 +593,32 @@ def run_phase( return results -def print_summary(step_results: list[StepResult], verify_results: list[StepResult]) -> int: - def summarize(phase: str, results: list[StepResult]) -> tuple[int, int, int]: - passed = sum(1 for r in results if r.effectively_passed and not r.ignored) - failed = sum(1 for r in results if not r.effectively_passed) - skipped = sum(1 for r in results if r.ignored) - return passed, failed, skipped +def _tally(results: list[StepResult]) -> dict[str, int]: + return {s: sum(1 for r in results if r.state == s) for s in STATES} + +def print_summary(step_results: list[StepResult], verify_results: list[StepResult]) -> int: print() print(BOLD("── summary ────────────────────────────────────────────")) for phase, res in [("steps", step_results), ("verify", verify_results)]: if not res: continue - passed, failed, skipped = summarize(phase, res) - color = GREEN if failed == 0 else RED - print( - f" {phase:<8} " - f"{color(f'{passed} passed')}, " - f"{RED(f'{failed} failed') if failed else DIM('0 failed')}, " - f"{DIM(f'{skipped} skipped')}" - ) - any_fail = any(not r.effectively_passed for r in step_results + verify_results) - exit_code = 1 if any_fail else 0 - overall = RED("FAILED") if any_fail else GREEN("OK") + t = _tally(res) + # Color the ok / changed counts only when there's something to report. + ok_s = GREEN(f"{t['ok']} ok") + chg_s = (YELLOW(f"{t['changed']} changed") if t["changed"] else DIM("0 changed")) + fail_s = (RED(f"{t['failed']} failed") if t["failed"] else DIM("0 failed")) + skip_s = DIM(f"{t['skipped']} skipped") + print(f" {phase:<8} {ok_s}, {chg_s}, {fail_s}, {skip_s}") + + any_fail = any(r.state == "failed" for r in step_results + verify_results) + any_changed = any(r.state == "changed" for r in step_results + verify_results) + if any_fail: + overall, exit_code = RED("FAILED"), 1 + elif any_changed: + overall, exit_code = YELLOW("CHANGED"), 0 + else: + overall, exit_code = GREEN("OK"), 0 print(f" overall: {overall}") return exit_code