module

Krikri::PythonActionRunner

Constants

TASK_FAILED_KIND = "task_failed"

The dispatched plugin raised AnsibleError inside run() - Ansible's own "this task failed" signal, not a runner crash (see ActionError #detail).

UNAVAILABLE_KIND = "unavailable"
WRAPPER = "import importlib.util\nimport json\nimport sys\nimport types\n\n\n_unavailable = None\ntry:\n from ansible.plugins.action import ActionBase\n from ansible.errors import AnsibleError\nexcept Exception as exc:\n _unavailable = \"%s: %s\" % (type(exc).__name__, exc)\n\n\ndef _load_plugin(path):\n modname = \"krikri_action_%d\" % hash(path)\n spec = importlib.util.spec_from_file_location(modname, path)\n if spec is None or spec.loader is None:\n raise ImportError(\"cannot load action plugin: %s\" % path)\n module = importlib.util.module_from_spec(spec)\n spec.loader.exec_module(module)\n return module\n\n\ndef _serialize(value):\n if isinstance(value, (set, frozenset)):\n return list(value)\n if isinstance(value, bytes):\n return value.decode(\"utf-8\", \"replace\")\n return str(value)\n\n\nclass _PassthroughTemplar(object):\n # Krikri hands the plugin task_vars as plain values (the\n # executor's own var context); Ansible would template them\n # again here, which is a no-op for the literal data the\n # supported plugins read (amtega.check_platform's\n # check_platform_distributions dict and friends, issue #411).\n # A plugin needing REAL templating gets its value untouched\n # and fails like a plugin crashing in Ansible would.\n\n def template(self, variable, **kwargs):\n return variable\n\n\ndef _emit(obj):\n # One protocol line: JSON with the marker that tells the engine\n # this is the wrapper speaking, not plugin noise on stdout.\n obj = dict(obj)\n obj[\"__krikri__\"] = True\n sys.stdout.write(json.dumps(obj, default=_serialize) + \"\\n\")\n sys.stdout.flush()\n\n\ndef _ipc_execute_module(self, module_name=None, module_args=None, **kwargs):\n # Ansible's real _execute_module transfers and runs a module\n # over a live connection - impossible on this stub runtime, so\n # the engine runs the named module instead (its own dispatch,\n # the task's host/connection/become) and the plugin gets back\n # the module's result dict. agaffney.synology_dsm's\n # synology_dsm_api_request builds 'uri' params and delegates\n # exactly this way, and the uri result IS the task result\n # (issue #399 - live-verified vs 2.19.11).\n name = module_name or self._task.action\n args = module_args if module_args is not None else dict(self._task.args)\n _emit({\"type\": \"execute_module\", \"module\": name, \"args\": args})\n while True:\n line = sys.stdin.readline()\n if line == \"\":\n raise AnsibleError(\"krikri action dispatch channel closed\")\n try:\n msg = json.loads(line)\n except ValueError:\n continue\n if not isinstance(msg, dict) or not msg.get(\"__krikri__\"):\n continue\n if msg.get(\"error\") is not None:\n raise AnsibleError(msg[\"error\"])\n result = msg.get(\"result\")\n return result if result is not None else {}\n\n\ndef _ipc_remove_tmp_path(self, tmp_path=None, force=False):\n # The real method deletes the tmp dir over the connection shell;\n # krikri's inner dispatch manages the tmp dirs of the modules it\n # runs, so this is a no-op here rather than a crash on the stub\n # connection (a plugin that cleans up after _execute_module -\n # synology_dsm_api_request does - would otherwise die in\n # _low_level_execute_command).\n return None\n\n\ndef main():\n payload = json.loads(sys.stdin.readline())\n if _unavailable is not None:\n _emit({\"ok\": False, \"kind\": \"unavailable\",\n \"error\": \"ansible is not importable by the controller python3: %s\"\n % _unavailable})\n return\n try:\n module = _load_plugin(payload.get(\"path\"))\n except ImportError as exc:\n _emit({\"ok\": False, \"kind\": \"load\", \"error\": str(exc)})\n return\n plugin_class = getattr(module, \"ActionModule\", None)\n if plugin_class is None or not isinstance(plugin_class, type) or \\\n not issubclass(plugin_class, ActionBase):\n _emit({\"ok\": False, \"kind\": \"not_found\",\n \"error\": \"no ActionModule subclass of ActionBase in %s\"\n % payload.get(\"path\")})\n return\n task = types.SimpleNamespace(\n args=payload.get(\"args\") or {},\n action=payload.get(\"name\"),\n async_val=0,\n check_mode=bool(payload.get(\"check_mode\")),\n become=bool(payload.get(\"become\")),\n become_user=payload.get(\"become_user\"),\n diff=False,\n poll=0,\n no_log=False,\n environment=[],\n delegate_to=None,\n )\n # The non-None tmpdir placeholder keeps ActionBase.run's\n # `_connection._shell.tmpdir is None` early check from calling\n # _make_tmp_path - which would need a real connection shell.\n connection = types.SimpleNamespace(\n transport=payload.get(\"connection\"),\n _shell=types.SimpleNamespace(tmpdir=\"/tmp/krikri-action-tmpdir\"),\n )\n play_context = types.SimpleNamespace(\n connection=payload.get(\"connection\"),\n remote_addr=payload.get(\"remote_addr\"),\n port=payload.get(\"port\"),\n remote_user=payload.get(\"remote_user\"),\n become=bool(payload.get(\"become\")),\n become_user=payload.get(\"become_user\"),\n become_method=None,\n become_flags=None,\n check_mode=bool(payload.get(\"check_mode\")),\n diff=False,\n )\n # ansible-core 2.19 still passes shared_loader_obj on every action\n # plugin instantiation (task_executor._get_action_handler), even\n # though ActionBase.__init__ made it optional (shared_loader_obj=None)\n # in that release - a legacy plugin whose own __init__ declares it\n # as a REQUIRED positional parameter (wcm_io_devops.conga_facts,\n # issue #658) therefore dies with \"missing 1 required positional\n # argument\" unless the engine passes it too, like Ansible does.\n instance = plugin_class(task, connection, play_context, None, _PassthroughTemplar(), None)\n if payload.get(\"ipc\"):\n instance._execute_module = types.MethodType(_ipc_execute_module, instance)\n instance._remove_tmp_path = types.MethodType(_ipc_remove_tmp_path, instance)\n try:\n result = instance.run(task_vars=payload.get(\"task_vars\") or {})\n except AnsibleError as exc:\n # An AnsibleError raised by the plugin itself IS the task's\n # failure (Ansible's task executor reports it as the plain\n # action-level \"Task failed: <text>\" shape, live-verified vs\n # 2.19.11) - report the bare text under its own kind, not\n # the class-name-prefixed runner-crash shape.\n _emit({\"ok\": False, \"kind\": \"task_failed\", \"error\": \"%s\" % exc})\n return\n _emit({\"ok\": True, \"result\": result})\n\n\ntry:\n main()\n# BaseException, not Exception: a plugin's `sys.exit()` raises\n# SystemExit, which would otherwise kill the wrapper before any\n# JSON response - reported as a kind \"error\" plugin failure here\n# rather than silently degrading the whole mechanism.\nexcept BaseException as exc:\n _emit({\"ok\": False, \"kind\": \"error\",\n \"error\": \"%s: %s\" % (type(exc).__name__, exc)})"

The controller-side wrapper: imports the plugin file, finds its ActionModule class (required to subclass the real ansible.plugins.action.ActionBase, exactly like Ansible's own loader check), instantiates it with the stub task/connection/ play_context objects, and calls run(task_vars=...), printing the JSON-encoded result. Always responds with a single-line JSON object ({"ok": true, ...} / {"ok": false, kind, error}), so a plugin that prints its own noise cannot corrupt the protocol - only the LAST line of stdout is parsed. An ansible package the controller python3 cannot import reports kind "unavailable" (the whole mechanism degrades; the plugin file could not have imported its own base class either).

Instance methods

python_executable

The CONTROLLER's own python3 - the interpreter Ansible needs on the controller anyway (same detection pattern as PythonLookupRunner#python_executable).

Source
run_action(source : String, name : String, args : Hash(String, JSON::Any), connection : String, remote_addr : String, port : Int32 | Nil, remote_user : String | Nil, become : Bool, become_user : String | Nil, check_mode : Bool, task_vars : Hash(String, JSON::Any), dispatcher : ModuleDispatcher | Nil = nil) : JSON::Any

Invokes the ActionModule from source with the substituted task args (already typed: containers JSON-decoded, scalars native) and the connection/play-context values Ansible's own play_context carries for THIS task (remote_addr/port resolved from the connection, port null when unset - ansible's play_context.port is None there, verified against ansible-playbook 2.19.11). Raises ActionUnavailableError when the mechanism is unavailable, ActionError for a dispatched failure; returns the plugin's result dict on success.

dispatcher, when given, answers the plugin's self._execute_module(...) calls through the engine's own module dispatch (see run_wrapper_ipc); without it such a plugin still fails with its own exception, the pre-existing stub behavior.

Source

Nested types