elway: add tier 1 + tier 2 idempotency

Tier 1 — pre-step skip conditions:
  when:    <remote shell expr>   skip unless expr exits 0
  creates: <remote path>         skip if path already exists
  removes: <remote path>         skip if path is already absent
Any of the three saying "skip" marks the step `skipped` and moves on.
Evaluated under bash -c on the remote so `!`, `[[`, pipes etc. behave
consistently regardless of the default remote shell.

Tier 2 — post-step change detection:
  changed_when: <remote shell expr>
Evaluated after a successful step. Exit 0 → step counts as `changed`
(default). Exit != 0 → `ok` (ran, nothing actually different).
Without this field, successful steps default to `changed`, matching
Ansible's shell/command defaults. Useful on verify steps:
`changed_when: "false"` reports them as `ok` since they only attest.

Status model moved from pass/fail to four states:
  ok / changed / failed / skipped
Summary reports each count; overall outcome is CHANGED if any step
changed, OK if none did, FAILED on any non-skipped failure.

Rerunnable smoke: playbooks/elway-smoke.yaml now proves it. On a
clean target the cold run reports 4 changed, 3 ok. Rerunning with
the same vars reports 2 skipped / 2 changed (upload + log-record
have no idempotency hooks and are always `changed`). Overriding
--var greeting=... re-runs the gated step exactly as intended.

Doc block at the top of the script updated with the new schema
fields and state machine.
This commit is contained in:
2026-04-24 10:39:48 -07:00
parent dea95bf526
commit f115c982bc
2 changed files with 197 additions and 55 deletions
+27 -11
View File
@@ -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"
+170 -44
View File
@@ -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: "<remote shell expr>" # skip unless expr exits 0
creates: <remote path> # skip if this path already exists
removes: <remote path> # 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: "<remote shell expr>"
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