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:
+27
-11
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user