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),
|
# elway's own smoke-test playbook. Exercises:
|
||||||
# multi-step flow, upload with mode, stop-on-error semantics, verify phase.
|
# - vars (inline default + CLI override)
|
||||||
# Target: any Linux host reachable over ssh that has /tmp and curl.
|
# - 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:
|
vars:
|
||||||
scratch_dir: /tmp/elway-smoke
|
scratch_dir: /tmp/elway-smoke
|
||||||
greeting: hello from elway
|
greeting: hello from elway
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clean any prior scratch dir
|
|
||||||
shell: rm -rf {{ scratch_dir }}
|
|
||||||
|
|
||||||
- name: Create scratch dir
|
- name: Create scratch dir
|
||||||
shell: mkdir -p {{ scratch_dir }}
|
shell: mkdir -p {{ scratch_dir }}
|
||||||
|
creates: "{{ scratch_dir }}"
|
||||||
|
|
||||||
- name: Drop a greeting file
|
- name: Drop a greeting file
|
||||||
shell: echo "{{ greeting }}" > {{ scratch_dir }}/greeting.txt
|
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
|
- name: Upload this playbook into the scratch dir
|
||||||
upload:
|
upload:
|
||||||
@@ -22,12 +31,19 @@ steps:
|
|||||||
dest: "{{ scratch_dir }}/uploaded.yaml"
|
dest: "{{ scratch_dir }}/uploaded.yaml"
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Record that we ran (a step with no idempotency hooks — always `changed`)
|
||||||
|
shell: date -Iseconds > {{ scratch_dir }}/last-run.txt
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
- name: Greeting file has the expected content
|
- 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
|
- name: Uploaded yaml is non-empty
|
||||||
shell: test -s {{ scratch_dir }}/uploaded.yaml && head -1 {{ scratch_dir }}/uploaded.yaml
|
shell: test -s {{ scratch_dir }}/uploaded.yaml
|
||||||
|
changed_when: "false"
|
||||||
|
|
||||||
- name: Scratch dir exists and is listable
|
- name: last-run.txt exists
|
||||||
shell: ls -la {{ scratch_dir }}
|
shell: test -s {{ scratch_dir }}/last-run.txt
|
||||||
|
changed_when: "false"
|
||||||
|
|||||||
+170
-44
@@ -37,13 +37,29 @@ Playbook schema (YAML):
|
|||||||
mode: "0644" # optional
|
mode: "0644" # optional
|
||||||
sudo: false # default false
|
sudo: false # default false
|
||||||
stop_on_fail: true # optional per-step override of global flag
|
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)
|
verify: # optional; always runs after steps (even on fail)
|
||||||
- name: Port responds
|
- name: Port responds
|
||||||
shell: curl -sf http://localhost:8188/ >/dev/null
|
shell: curl -sf http://localhost:8188/ >/dev/null
|
||||||
|
|
||||||
Exit codes:
|
Per-step state machine:
|
||||||
0 — every step (+ verify) passed or was ignored
|
ok — ran successfully, changed_when reported no change
|
||||||
1 — a non-ignored step failed
|
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
|
2 — usage / config error before execution began
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -109,6 +125,19 @@ class Step:
|
|||||||
upload: Optional[UploadSpec] = None
|
upload: Optional[UploadSpec] = None
|
||||||
sudo: bool = False
|
sudo: bool = False
|
||||||
stop_on_fail: Optional[bool] = None # None = inherit global
|
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:
|
def kind(self) -> str:
|
||||||
return "shell" if self.shell is not None else "upload"
|
return "shell" if self.shell is not None else "upload"
|
||||||
@@ -121,23 +150,29 @@ class Playbook:
|
|||||||
verify: list[Step]
|
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
|
@dataclasses.dataclass
|
||||||
class StepResult:
|
class StepResult:
|
||||||
step: Step
|
step: Step
|
||||||
phase: str # "steps" or "verify"
|
phase: str # "steps" or "verify"
|
||||||
index: int
|
index: int
|
||||||
total: int
|
total: int
|
||||||
exit_code: int
|
state: str # one of STATES
|
||||||
duration_s: float
|
exit_code: int = 0
|
||||||
ignored: bool = False
|
duration_s: float = 0.0
|
||||||
|
skip_reason: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def passed(self) -> bool:
|
def succeeded(self) -> bool:
|
||||||
return self.exit_code == 0
|
# "succeeded" = didn't fail. Skipped and ok and changed all count.
|
||||||
|
return self.state != "failed"
|
||||||
@property
|
|
||||||
def effectively_passed(self) -> bool:
|
|
||||||
return self.passed or self.ignored
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Template substitution ─────────────────────────────────────────────────
|
# ─── 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"),
|
dest=substitute(step.upload.dest, vars, f"{where} upload.dest"),
|
||||||
mode=step.upload.mode,
|
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
|
return new
|
||||||
|
|
||||||
|
|
||||||
@@ -207,12 +246,20 @@ def _load_step(raw: dict, where: str) -> Step:
|
|||||||
dest=_as_str(u["dest"], f"{where} upload.dest"),
|
dest=_as_str(u["dest"], f"{where} upload.dest"),
|
||||||
mode=str(mode) if mode is not None else None,
|
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(
|
return Step(
|
||||||
name=_as_str(raw["name"], f"{where} name"),
|
name=_as_str(raw["name"], f"{where} name"),
|
||||||
shell=shell,
|
shell=shell,
|
||||||
upload=upload,
|
upload=upload,
|
||||||
sudo=bool(raw.get("sudo", False)),
|
sudo=bool(raw.get("sudo", False)),
|
||||||
stop_on_fail=raw.get("stop_on_fail"),
|
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 ────────────────────────────────────────────────────
|
# ─── 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(
|
def run_phase(
|
||||||
ctx: SSHContext,
|
ctx: SSHContext,
|
||||||
phase: str,
|
phase: str,
|
||||||
@@ -427,10 +535,9 @@ def run_phase(
|
|||||||
print(header)
|
print(header)
|
||||||
|
|
||||||
if halted:
|
if halted:
|
||||||
print(f" {DIM('SKIPPED (earlier failure)')}")
|
_print_status("skipped", 0, 0.0, "earlier step failed")
|
||||||
results.append(
|
results.append(StepResult(resolved, phase, i, len(steps),
|
||||||
StepResult(resolved, phase, i, len(steps), exit_code=0, duration_s=0.0, ignored=True)
|
state="skipped", skip_reason="earlier step failed"))
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -440,11 +547,25 @@ def run_phase(
|
|||||||
u = resolved.upload
|
u = resolved.upload
|
||||||
mode = f" mode={u.mode}" if u.mode else ""
|
mode = f" mode={u.mode}" if u.mode else ""
|
||||||
print(f" {DIM('dry-run:')} upload: {u.src} → {u.dest}{mode}")
|
print(f" {DIM('dry-run:')} upload: {u.src} → {u.dest}{mode}")
|
||||||
results.append(
|
if resolved.when or resolved.creates or resolved.removes:
|
||||||
StepResult(resolved, phase, i, len(steps), exit_code=0, duration_s=0.0, ignored=True)
|
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
|
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}]')} "
|
prefix = f" {DIM(f'[{phase[0]}{i}]')} "
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
if resolved.kind() == "shell":
|
if resolved.kind() == "shell":
|
||||||
@@ -453,15 +574,17 @@ def run_phase(
|
|||||||
rc = run_upload_step(ctx, resolved, prefix)
|
rc = run_upload_step(ctx, resolved, prefix)
|
||||||
elapsed = time.monotonic() - t0
|
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:
|
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
|
step_stop = resolved.stop_on_fail
|
||||||
effective_stop = global_stop_on_fail if step_stop is None else step_stop
|
effective_stop = global_stop_on_fail if step_stop is None else step_stop
|
||||||
if effective_stop and phase == "steps":
|
if effective_stop and phase == "steps":
|
||||||
@@ -470,29 +593,32 @@ def run_phase(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def print_summary(step_results: list[StepResult], verify_results: list[StepResult]) -> int:
|
def _tally(results: list[StepResult]) -> dict[str, int]:
|
||||||
def summarize(phase: str, results: list[StepResult]) -> tuple[int, int, int]:
|
return {s: sum(1 for r in results if r.state == s) for s in STATES}
|
||||||
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 print_summary(step_results: list[StepResult], verify_results: list[StepResult]) -> int:
|
||||||
print()
|
print()
|
||||||
print(BOLD("── summary ────────────────────────────────────────────"))
|
print(BOLD("── summary ────────────────────────────────────────────"))
|
||||||
for phase, res in [("steps", step_results), ("verify", verify_results)]:
|
for phase, res in [("steps", step_results), ("verify", verify_results)]:
|
||||||
if not res:
|
if not res:
|
||||||
continue
|
continue
|
||||||
passed, failed, skipped = summarize(phase, res)
|
t = _tally(res)
|
||||||
color = GREEN if failed == 0 else RED
|
# Color the ok / changed counts only when there's something to report.
|
||||||
print(
|
ok_s = GREEN(f"{t['ok']} ok")
|
||||||
f" {phase:<8} "
|
chg_s = (YELLOW(f"{t['changed']} changed") if t["changed"] else DIM("0 changed"))
|
||||||
f"{color(f'{passed} passed')}, "
|
fail_s = (RED(f"{t['failed']} failed") if t["failed"] else DIM("0 failed"))
|
||||||
f"{RED(f'{failed} failed') if failed else DIM('0 failed')}, "
|
skip_s = DIM(f"{t['skipped']} skipped")
|
||||||
f"{DIM(f'{skipped} skipped')}"
|
print(f" {phase:<8} {ok_s}, {chg_s}, {fail_s}, {skip_s}")
|
||||||
)
|
|
||||||
any_fail = any(not r.effectively_passed for r in step_results + verify_results)
|
any_fail = any(r.state == "failed" for r in step_results + verify_results)
|
||||||
exit_code = 1 if any_fail else 0
|
any_changed = any(r.state == "changed" for r in step_results + verify_results)
|
||||||
overall = RED("FAILED") if any_fail else GREEN("OK")
|
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}")
|
print(f" overall: {overall}")
|
||||||
return exit_code
|
return exit_code
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user