Skip to content

Salt result recovery with durable minion receipts

The repository implements protocol 1 in the oduflow_job minion execution module and the oduflow master runner. Deployment and live verification must be reported separately: local protocol tests do not prove that an existing client has received the module or survived a real transport failure.

This protocol addresses a completed state result lost during Salt publication, including master IPC failures. It does not recreate receipts for older jobs. Existing ambiguous legacy dispatches still require explicit reconciliation and must never trigger another automatic apply.

Execution and identity

The master persists its request UUID, exact client identity, role/profile and 20-digit Salt JID before publication. New requests require the minion's protocol capability; failure to verify it stops dispatch. The master publishes oduflow_job.run(request_id, profile, state_data_json) once, using that JID. It records the dispatch intent before calling Salt, so a lost acknowledgement cannot authorize another publication.

The wrapper receives the actual JID through Salt's __pub_jid metadata. In pinned Salt 3006.27, load_args_and_kwargs injects publication metadata after parsing caller arguments, replacing any caller-supplied __pub_jid. Upstream argument binding.

The minion validates a canonical client-<UUID> identity, canonical request UUID, exact JID and profile. Supported profiles map to fixed roles:

Profile Role
configure roles.client_stack
production roles.client_production
volume_resize roles.client_storage_resize
credentials roles.client_credentials
custom Canonical bounded high data, bound by SHA-256 digest

The wrapper rejects caller execution options. Fixed-role calls set test=False, queue=False, concurrent=False and state_events=False. Salt's state.apply with a role delegates synchronously to state.sls; state.high also returns its result synchronously. A compiler/conflict list, exception, empty mapping or non-boolean state result cannot become a successful receipt. Upstream state execution.

Durable state machine

Receipts live under /var/lib/oduflow/job-receipts, owned by root with mode 0700; each file has mode 0600. Directory traversal and file opens refuse symlinks, unsafe ownership/permissions and multiply linked files. JSON writes use an exclusive temporary file, file fsync, atomic rename and directory fsync.

A nonblocking execution lock serializes managed applies on one minion. A separate request lock distinguishes a living worker from an abandoned claim. The JID index and running request record are committed before calling the state function. These are two separate atomic writes: an interruption between them leaves a JID index without a request record and occurs before any state work. An incomplete claim for the same publication remains unknown and cannot execute again. A request is never recovered by assigning a new JID. The minion does not scan unrelated JID indexes to discover this incomplete binding; the master must retain its original JID even when only the first write survived.

After state execution, the wrapper reduces its result and commits this schema before returning to Salt:

{
  "protocol": 1,
  "minion_id": "client-<canonical UUID>",
  "request_id": "<canonical UUID>",
  "jid": "<exact 20-digit Salt JID>",
  "profile": "configure",
  "state_digest": "",
  "status": "succeeded",
  "passed": 60,
  "total": 60
}

Success requires a nonempty state mapping whose results are all exactly True. A valid mapping containing False produces failed; unstructured results and exceptions produce unknown. No state names, comments, changes, commands, stdout/stderr, pillar or credentials enter the reduced receipt or return.

Failure boundary Recovery
No matching local evidence not_started; this does not authorize redispatch of a published master claim
Partial claim or worker killed before terminal commit unknown; no automatic replay
Worker running with matching request lock running
Terminal commit completed, publication lost Exact terminal receipt remains readable
Identity, request, JID, profile or digest conflicts Reject the receipt
Receipt inaccessible or malformed Remain unresolved

The master polls oduflow_job.status only on the expected authenticated minion, validating the complete schema and exact immutable binding. Network I/O occurs outside the master record lock; after reacquiring it, a poll preserves any terminal result already committed by another worker. A target with an unresolved published request blocks another managed dispatch.

Why a returner is insufficient

Salt 3006.27 calls _return_pub before normal custom returners in both its single-function and multiple-function paths. An unexpected publication exception can prevent a returner from running. This wrapper instead commits inside the execution function, before control reaches publication. Single-function ordering, multiple-function ordering.

Raw-result suppression and runtime boundary

Global job and pillar caches remain disabled. These settings alone are insufficient: pinned state.sls writes sls.p and compiled *.cache.p files regardless of cache_jobs=False or the call's cache-read option. The wrapper intercepts those writes into disposable in-memory streams within its isolated minion worker and refuses reads through that cache path. Unconditional state caches.

The wrapper also suppresses State.event, since individual fire_event options can otherwise publish raw state returns even with state_events=False. These shims require exactly Salt 3006.27 and multiprocessing=True; they are restored before returning and do not affect other minion workers. Per-state event behavior.

Per-state parallel execution is refused. Salt otherwise writes raw parallel returns below <cachedir>/<invocation_id>/<state-tag-hash>, outside the two ordinary state cache files. Custom payloads reject any explicit parallel option, and the isolated worker guards State.call_parallel before it can spawn. Parallel result persistence.

The receipt is evidence about the synchronous state result, not current service health. Administrator-authored custom states can start background work or call external services; a successful outer state does not prove that such work has finished. Application and infrastructure verification remains separate.

Bootstrap and verification

Cloud-init carries the module as a reviewed bootstrap asset and installs it in the minion extension-module directory before starting the managed minion. Salt's client_receipts state keeps that file managed afterward. Capability checking prevents a new request from silently falling back to unreceipted execution on an older client. Legacy master records retain their original interpretation.

tests/test_minion_receipts.py covers reduced schema, identity/role restrictions, private storage, duplicate execution and pinned-Salt loader/state integration. tests/test_salt_receipt_protocol.py independently uses real process forks, SIGKILL, file locks and a local side-effect marker to verify interruption before and after terminal commit, lost publication, concurrent polls and duplicate workers. These tests do not publish network jobs or perform live provisioning. Master runner and bootstrap tests verify their respective delivery contracts.

Salt's older mutable sls.p artifact is not an immutable JID-bound receipt. Never adopt it automatically or expose its raw contents in an operation journal. Missing evidence remains unknown even when timestamps or state counts appear plausible.