"""Exec: run a command and hand back what it said. The command runs inside the backend container, not on the host. That matters when porting: a flow that read the host's journal or poked a host script needs either a mount or a small listener on the host side, not this node. """ from __future__ import annotations import logging import shlex import subprocess from collections.abc import Iterable from typing import Any from pydantic import BaseModel, ConfigDict, Field from fluksio.flow.messages import MessageSpec from fluksio.flow.nodes.base import Node logger = logging.getLogger(__name__) class ExecNode(Node): """Run a command, returning its output, error text and exit code.""" # Running a command twice is running it twice. idempotent = False class Params(BaseModel): model_config = ConfigDict(extra="allow") command: str = Field(description="The command to run.") append_payload: bool = Field( default=False, description="Add the incoming value to the command as one argument.", ) timeout: float = Field( default=30.0, gt=0, description="Give up after this many seconds." ) fail_on_error: bool = Field( default=False, description="Treat a non-zero exit as a node failure rather than output.", ) __slots__ = ("cfg",) def __init__( self, requires: MessageSpec | Iterable[MessageSpec] = (), provides: MessageSpec | Iterable[MessageSpec] = (), params: dict[str, Any] | None = None, name: str | None = None, ): self.cfg = self.Params.model_validate(params or {}) super().__init__( f=self._run, requires=requires, provides=provides, params=params, name=name or "exec", ) def _run(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: argv = shlex.split(self.cfg.command) if not argv: raise ValueError("exec node has no command") if self.cfg.append_payload and kwargs: argv.append(str(next(iter(kwargs.values())))) try: # No shell: the command is a list, so a value carrying a semicolon # is an argument rather than a second command. completed = subprocess.run( argv, capture_output=True, text=True, timeout=self.cfg.timeout, check=False, ) except subprocess.TimeoutExpired as exc: raise TimeoutError( f"'{argv[0]}' did not finish within {self.cfg.timeout}s" ) from exc except FileNotFoundError as exc: raise FileNotFoundError( f"'{argv[0]}' is not available in this container" ) from exc if self.cfg.fail_on_error and completed.returncode != 0: raise RuntimeError( f"'{argv[0]}' exited {completed.returncode}: " f"{completed.stderr.strip()[:200]}" ) available = { "stdout": completed.stdout, "stderr": completed.stderr, "code": completed.returncode, } # Ports named after one of those get it; anything else gets stdout, # which is what a single-output exec node is almost always after. return { spec.port: available.get(spec.port, completed.stdout) for spec in self.output_ports } or None