class

Krikri::TaskExecutor

Inherits Reference < Object

TaskExecutor - Executes tasks on hosts Orchestrates task execution, variable substitution, and handler management

Constants

DELIMITERS_DEPRECATION_FOOTER = "\n"

Real 2.19's delimiters deprecation ends with a blank line after the Origin block's caret line, the same trailing-blank shape the #722 constant-template warning's footer carries (byte-compared vs 2.19.11, issue #786 - antmelekhin.mysqld_exporter's 1-byte recap diff).

DELIMITERS_DEPRECATION_HEADER = "[DEPRECATION WARNING]: Conditionals should not be surrounded by templating delimiters such as {{ }} or {% %}. This feature will be removed from ansible-core version 2.23.\n"
DICT_FAILURE_ACTION_PLUGINS = {"yum", "dnf", "ansible.builtin.yum", "ansible.builtin.dnf", "ansible.legacy.yum", "ansible.legacy.dnf"}

yum/dnf belong here alongside script: real's dnf ACTION plugin resolves its backend and, when it cannot, RETURNS a failed dict (result.update({'failed': True, 'msg': ...}) in dnf.py) instead of raising - so the executor's attempt loop sees an ordinary failed result and retries it like any other failure (live-verified vs 2.19.11). Their _ansible_action_level-marked results therefore must NOT take the raised-exception escape from run_until_retries.

DO_TAG_SYNTAX_PREFIX = "Syntax error in template: Encountered unknown tag 'do'"
INLINE_COPY_MAX_BYTES = (8 * 1024) * 1024

copy.cr runs on the target host (uploaded there like every other plugin, unlike template: - an action plugin that runs on the controller and already handles this correctly by reading its own src: file locally before dispatch). A copy: task's src: names a file on the controller, so copy.cr's own File.exists?(src) check - a plain local filesystem check, from the perspective of wherever it's actually running - can never find it once the play targets a genuinely remote host: the file was never transferred there. Found via konstruktoid-hardening's "Add cracklib password list" (its only copy: task with a real src: file, previously entirely untested territory) - failed with "Source file not found" citing the exact real (and really-existing-on-the-controller) path.

Read here, on the controller, and forwarded as content: instead - copy.cr already has a fully-working content-write path (used by any copy: {content: ..., dest: ...} task), so this reuses it rather than needing a separate upload mechanism. Left alone for remote_src: true (Ansible's own remote-to-remote copy, where src: already refers to a path on the target, not the controller - reading it here would be wrong) and for a local connection (copy.cr already runs on the same filesystem as the controller in that case, so src: already resolves correctly as-is - converting it anyway would only change check-mode's message ("Would copy SRC to DEST" -> a generic content message) for no actual correctness gain). Above this size, embedding the file as a content param string would make the JSON config too large to safely round-trip through #execute_remote_plugin's own base64 encoding of the whole config (see the comment on that call): a real bug found benchmarking ansible-community.ansible-vault's own "Install Vault" task, which copy:s a ~530MB downloaded Vault release binary - Crystal stdlib's Base64.encode_size computes str_size * 4 as native Int32 arithmetic before the final .to_i, and a base64'd-then- JSON-escaped-then-base64'd-again 530MB payload comfortably clears 2^31, crashing the whole engine with an unhandled OverflowError partway through a run - not a graceful per-task failure.

INVOCATION_FQCNS = ["community.crypto.x509_certificate_info"] of ::String

Collection modules with the same verified shape, by FQCN (after alias resolution). x509_certificate_info: path, the two defaults, then content/valid_at null (#367, pacifica.ansible_certinfra's loop over its CA chain).

INVOCATION_MODULES = ["command", "shell", "stat", "file", "ping", "slurp", "lineinfile", "replace", "blockinfile", "find", "getent", "get_url"] of ::String

Modules whose registered per-item results (loop results[]) carry Ansible's invocation.module_args and whose args this engine can reproduce from the argspec table (verified against ansible-core 2.19.11).

JINJA_CONST_TEMPLATE_WARNING_FOOTER = "\nUse inline expressions, for example: `when: \"{{ a_var }}\" == 42` becomes `when: a_var == 42`\n\n"
JINJA_CONST_TEMPLATE_WARNING_HEADER = "[WARNING]: Jinja constant strings should not contain embedded templates. This feature will be disabled by default in ansible-core 2.23.\n"

Real 2.19's constant-template warning on conditionals is a plain [WARNING], not a deprecation: no disable-hint line, and after the Origin block it carries an inline-expression hint and a trailing blank line (byte-compared vs 2.19.11, issue #722).

LOOP_LOOKUP_TERM_VAR = "krikri_loop_lookup_term"
MARKUP_STORAGE_PRESERVING_FILTERS = ["trim", "safe", "string", "default", "d", "lower", "upper", "capitalize", "title"] of ::String
NO_CHECK_MODE_MODULES = ["ansible.builtin.uri", "ansible.builtin.wait_for", "ansible.builtin.tempfile", "community.mysql.mysql_query", "community.mysql.mysql_variables"] of ::String

Data-driven module argument validation (see ArgspecValidator): the failing result JSON for this task's module args, or nil when validation passes or does not apply. Ansible runs these checks inside the module's own AnsibleModule init - i.e. after the action plugin stage, before any module-side file access - which is exactly where the two callers of this hook sit. Core modules whose Ansible module source declares supports_check_mode=False (and whose plugin mirrors the skip).

NO_INTERPRETER_MODULES = Set {"raw", "ansible.builtin.raw", "ansible.legacy.raw", "command", "ansible.builtin.command", "ansible.legacy.command", "shell", "ansible.builtin.shell", "ansible.legacy.shell", "script", "ansible.builtin.script", "ansible.legacy.script"}

Modules real executes WITHOUT its python interpreter machinery: raw/command/shell/script hand their payload straight to the connection's shell, so no interpreter is resolved for them and a missing interpreter cannot fail them (every other module reaching the local dispatch is a python-module run as far as the interpreter question goes).

RAW_MODULES = TaskBatcher::RAW_MODULES

One definition, shared with the batcher's own raw exclusion (TaskBatcher::raw_task?) - raw also aliases to shell, so a second hand-maintained copy here would drift silently.

RESERVED_LOOP_VAR_WARN_MUTEX = Mutex.new
RESERVED_SET_FACT_WARN_MUTEX = Mutex.new
SCRIPT_MODULES = {"script", "ansible.builtin.script", "ansible.legacy.script"}
UNTIL_EXPRESSION_FAILED_PREFIX = "An 'until' expression failed: "

loop_control.label - what the per-item line shows instead of the raw item. Rendered against that item's own context, so it can name a field of the item. Shared until: retry core - drives task.retries/task.delay attempts of execute_task_once (or the caller's attempt_body) until the rendered condition passes. Used both by execute_task_with_retries (single task, displays its final result) and, per item, by execute_looped_task's non-batched path (Ansible retries each loop item independently). The attempt_body Proc overrides the per-attempt body: execute_handler_ plugin_once passes its own single-attempt body so a handler's until:/retries:/delay: retries too - real prints the same FAILED - RETRYING callback lines and attempts key for handlers (issue #620, live-verified vs real ansible-playbook). ansible-core 2.21 labels an until:/retries: condition failure with the clause that raised it, exactly like when: ("Task failed: An 'until' expression failed: <detail>", live-captured vs 2.21.6).

WHEN_EXPRESSION_FAILED_PREFIX = "A 'when' expression failed: "

Render task's name: for the "TASK [...]" banner, lazily - only builds a vars_context (the same expensive facts-merge #execute_task itself pays for separately) when the name actually needs it, so a literal (the overwhelming majority) task name costs nothing extra.

Every banner print site used to print task.name raw, which only ever reflected whatever narrow, early, per-var-source pass had already substituted into it (include_tasks:'s include_vars:, include_role:'s vars:) - anything sourced from a role's own vars/main.yml (round 26/27's __common_binary_basename, itself a templated vars-file entry) or from ansible_facts/registered vars stayed literally {{ ... }} in the banner even though the task's own body rendered correctly, since the body gets a full vars_context at actual execution time and the banner never did. Rendering here, right before print, gives the banner the same full context the body itself is about to use - it's cosmetic-only (a mistake here can't affect what actually runs), so best-effort: on any substitution error, fall back to the raw unrendered name rather than raising. ansible-core 2.21 labels EVERY conditional-expression failure with the clause that raised it ("Task failed: A 'when' expression failed: <detail>", live-captured vs 2.21.6; the same clause label rides the [ERROR] chain's "caused by" stanza). 2.19 printed the detail alone.

Constructors

new(hosts : Array(Krikri::Host), tasks : Array(Krikri::Task), handlers : Array(Krikri::Task) = [] of Task, check_mode : Bool = false, diff_mode : Bool = false, verbosity : Int32 = 0, play_vars : Hash(String, JSON::Any) = {} of String => JSON::Any, all_role_defaults : Hash(String, JSON::Any) = {} of String => JSON::Any, all_role_vars : Hash(String, JSON::Any) = {} of String => JSON::Any, play_var_origins : Hash(String, Krikri::VarOrigin) = {} of String => VarOrigin, all_role_default_origins : Hash(String, Krikri::VarOrigin) = {} of String => VarOrigin, all_role_var_origins : Hash(String, Krikri::VarOrigin) = {} of String => VarOrigin, gather_facts : Bool = true, inventory : Nil | Krikri::Inventory = nil, inventory_path : Nil | String = nil, batching_enabled : Bool = true, forks : Int32 = 5, smart_gathering : Bool = false, fact_store : Hash(String, Hash(String, JSON::Any)) | Nil = nil, set_fact_store : Hash(String, Hash(String, JSON::Any)) | Nil = nil, registered_store : Hash(String, Hash(String, JSON::Any)) | Nil = nil, adhoc : Bool = false, extra_vars : Hash(String, JSON::Any) = {} of String => JSON::Any, extra_var_origins : Hash(String, Krikri::VarOrigin) = {} of String => VarOrigin, force_handlers : Bool = false, vars_files : Array(Array(String)) = [] of Array(String), vars_files_dir : String = ".", playbook_dir : String = ".", any_errors_fatal : Bool = false, max_fail_percentage : Float64 | Nil = nil, unreachable_hosts : Set(String) = Set(String).new, unreachable_ssh_errors : Hash(String, String) = {} of String => String, strategy : String | Nil = nil, gather_subset : Array(String) = [] of String, gather_timeout : Int64 | Nil = nil, fact_path : String | Nil = nil, remote_user : String | Nil = nil, connection : String | Nil = nil, play_become : Bool = false, debugger : String | Nil = nil, playbook_file : String | Nil = nil, play_source_file : String | Nil = nil, play_source_line : Int32 = 0)
Source

Class methods

flush_deferred_finalization_error_block(result : JSON::Any) : Nil

Prints a result's stashed finalization error block, deduped across the whole run exactly like the inline emission path (Ansible's Display dedups by full formatted message - the Origin's file:line makes the text task-unique).

Source
raw_remote_pty_shape(hash : Hash(String, JSON::Any), remote : Bool, addr : String) : Nil

Real ansible runs raw over the connection with a pty (ssh -tt, because raw sends no stdin - issue #490's eendroroy.pyenv capture): the target's stdout and stderr arrive on the SAME pty, merged in execution order with ONLCR having turned every \n into \r\n, and the ssh client's own stderr holds only its mux close notice. Two capture shapes reach this:

  • The plugin captured the command on a REAL target-side pty (PtyCommandRunner - needed because a pty changes how isatty-sensitive programs like apt behave, issue #775). Its _krikri_real_pty marker says the stdout already IS that pty stream: nothing to merge or re-translate, and the marker comes back off the result here. The ssh client's close notice is synthesized the same way as for the emulated capture.
  • The pipe emulation ({ ...; } 2>&1, ScriptPtyCapture's pty_merged_command - the fallback for pty-less targets): the plugin's stdout carries both streams in order but still plain \n, and ONLCR gets re-applied here. (The stream merge happens at the source so the plugin's stderr is empty; appending the (empty) stderr stays harmless and keeps results that reached this path without the marker shaped.)

Local raw has no pty, so it keeps the captured streams untouched. Rewrites the hash in place to keep the shaped result's key order intact.

Source
raw_spawn_failure_shape(hash : Hash(String, JSON::Any), executable : String, remote : Bool, addr : String) : JSON::Any | Nil

The shell plugin pre-checks the task's executable: locally and reports the raw errno as rc (2 ENOENT, 13 EACCES) with msg "Error executing command." - but real's raw action never spawns the executable itself. It passes it to _low_level_execute_command, which sends <executable> -c '<cmd>' through the CONNECTION's own login shell; the missing/inexecutable binary is then reported BY that shell as an ordinary non-zero exit - 127 for ENOENT, 126 for EACCES - which the raw action wraps in the plain failed dict: changed: true, msg "non-zero return code", and none of the spawn report's cmd/exception keys. The wording differs by branch because the shells differ: local runs through /bin/sh (dash: "not found"), remote through the pty-attached ssh login shell - issue #631's capture shows bash's "No such file or directory" there (dash login shells would word it differently; not verified). Returns nil when the hash is not a raw spawn failure, so every other raw path keeps its shape.

Source
reboot_wait_unreachable_error(last_result : NamedTuple(exit_code: Int32, stdout: String, stderr: String) | Nil, timed_out : Bool, host : String, port : Int32 | Nil) : String | Nil

The reboot wait loop's UNREACHABLE-vs-FAILED decision, kept pure and public so the exit-137 / empty-stderr case (a probe killed by the exec timeout) stays testable. Returns the ssh error line for the UNREACHABLE result, or nil to keep the FAILED timeout path: Ansible reports UNREACHABLE when its ssh ConnectTimeout expires (a transport failure), but FAILED when the host is still reachable and only the test command keeps failing.

Source
stash_finalization_error_block(result : JSON::Any, text : String) : Nil

Batch-build (execute_batch_group / execute_looped_task_batched) prepares every member UP FRONT, before the earlier members' results display - printing the [ERROR] chain block during that preparation put it ahead of the PREVIOUS task's changed: line (issue #14, pylabs.nginx: the block surfaced under the previous task's header, ahead of its own result line). Instead the rendered text rides the failed result on this private marker - the same pattern as _ansible_loop_error_emitted - and the display path (finish_single_task / finish_looped_task) prints it just before the fatal/failed line, under the failing task's own banner.

Source

Instance methods

bump_hv_generation_for_add_host(module_name : String) : Nil

An add_host: action mutated the shared Inventory directly (the action plugin holds no executor reference), so - unlike every other mutation site below - it cannot bump @hv_generation itself. Called from the three ActionPluginManager.execute_action dispatch sites after a successful action: without this the hostvars/groups caches stay stale for the REST of the play and hostvars['<new host>'] reads "undefined" where Ansible exposes the host immediately (verified live vs 2.19.11: a same-play hostvars['dyn1'] read after add_host resolves with the full magic-var entry).

Source
check_mode=(check_mode : Bool)
Source
check_mode?
Source
cleared_error_hosts

Hosts a real failure halted in this play whose error state was since cleared via meta: clear_host_errors. Ansible's own documented semantics: "makes them available for targeting in subsequent plays, but not continue execution in the current play" - so, unlike ended_hosts, these stay in halted_hosts (the current play still stops for them) but are excluded from krikri-playbook.cr's cross-play carry-forward the same way.

Source
diff_mode=(diff_mode : Bool)
Source
diff_mode?
Source
ended_hosts

Subset of halted_hosts that got there via a CLEAN meta: end_host/ end_play, not a real task failure. Ansible's own semantics: "causes the play to end WITHOUT FAILING the host(s)" - such a host must still be excluded from the REST OF THIS PLAY (the existing halted_hosts mechanism already does that for free, including correctly propagating out of block:/rescue:/always: nesting and suppressing its own pending notified handlers - both verified against ansible-playbook to behave identically to a real failure for THIS play), but must NOT be treated as a failure by krikri-playbook.cr's cross-play carry-forward (permanently_failed_ hosts) or count toward the run's overall failed/exit-code status.

Source
gather_facts=(gather_facts : Bool)
Source
gather_facts?
Source
halted_hosts

Hosts that hit a failed task without ignore_errors: further tasks in the play are skipped for them (Ansible's default "a failure aborts the rest of the play for that host" behavior). Public - crystal- play.cr reads this after #run to carry a failed host forward and exclude it from every remaining play in the whole run too, not just the rest of this one (Ansible's actual behavior; see git log's 0.9.61-found, 0.9.64-fixed cross-cutting engine gap commit).

Source
handlers
Source
handlers=(handlers : Array(Task))
Source
hosts
Source
hosts=(hosts : Array(Host))
Source
play_aborted?

True once this play should stop for EVERY host. Also records the decision so krikri-playbook.cr can skip the remaining serial: batches

  • Ansible does not start the next batch after an abort (verified: serial: 1 + any_errors_fatal with h2 failing runs h1's batch fully, then stops; h3 never runs at all).
Source
play_vars
Source
play_vars=(play_vars : Hash(String, JSON::Any))
Source
reachable_unavailable_modules

Modules referenced by a task whose own when: (independent of the forced-skip #when_passes? always takes for an unavailable module) would have evaluated true for at least one host - i.e. genuinely REACHED, not merely present somewhere in the playbook text. Ansible only ever attempts module resolution for a task it's about to run, so a module referenced only inside a branch that's unreached on every host (when: ansible_os_family == "Suse" on an Ubuntu run) never contributes to its exit code - the previous whole-playbook static scan (PlaybookParser.unavailable_modules) flagged ANY unresolvable module name found anywhere in the file regardless of reachability, producing a false-positive exit 4 divergence from Ansible's own (often 0 or 2) exit code on every such role. Found via buluma.jenkins's own zypper_repository task (round 180), gated behind an OS-family branch that's false on every host this project benchmarks against (Ubuntu/RHEL-family).

Source
results

Track results for recap

Source
run

Main execution loop

Source
show_recap
Source
tasks
Source
tasks=(tasks : Array(Task))
Source
verbosity

ansible-playbook's -v/-vv/-vvv/... count, exposed to task templating as the ansible_verbosity magic var. See #build_vars_context's own assignment for the full rationale.

Source
verbosity=(verbosity : Int32)

ansible-playbook's -v/-vv/-vvv/... count, exposed to task templating as the ansible_verbosity magic var. See #build_vars_context's own assignment for the full rationale.

Source

Nested types