command.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. # -*- coding: utf-8 -*-
  2. """
  3. Command Action - Führt Shell-Befehle aus.
  4. """
  5. import asyncio
  6. import os
  7. import shlex
  8. from typing import Any
  9. from trixy_core.scheduler.action.base import Action
  10. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  11. class CommandAction(Action):
  12. """
  13. Action die einen Shell-Befehl ausführt.
  14. Beispiel:
  15. - CommandAction("echo 'Hello World'")
  16. - CommandAction(["python", "-c", "print('Hello')"])
  17. - CommandAction("cleanup.sh", cwd="/scripts", env={"DEBUG": "1"})
  18. Sicherheitshinweis:
  19. Diese Action führt System-Befehle aus.
  20. Nur für vertrauenswürdige Eingaben verwenden!
  21. """
  22. def __init__(
  23. self,
  24. command: str | list[str],
  25. cwd: str | None = None,
  26. env: dict[str, str] | None = None,
  27. shell: bool = False,
  28. capture_output: bool = True,
  29. name: str | None = None,
  30. timeout_seconds: float = 60.0,
  31. ):
  32. """
  33. Initialisiert die Action.
  34. Args:
  35. command: Befehl als String oder Liste
  36. cwd: Arbeitsverzeichnis
  37. env: Umgebungsvariablen (werden zu os.environ hinzugefügt)
  38. shell: Shell verwenden (Vorsicht bei untrusted input!)
  39. capture_output: stdout/stderr erfassen
  40. name: Action-Name
  41. timeout_seconds: Timeout
  42. """
  43. cmd_str = command if isinstance(command, str) else " ".join(command)
  44. super().__init__(name or f"command_{cmd_str[:30]}", timeout_seconds)
  45. self._command = command
  46. self._cwd = cwd
  47. self._env = env
  48. self._shell = shell
  49. self._capture_output = capture_output
  50. @property
  51. def command(self) -> str | list[str]:
  52. """Befehl."""
  53. return self._command
  54. @property
  55. def cwd(self) -> str | None:
  56. """Arbeitsverzeichnis."""
  57. return self._cwd
  58. async def _execute(self, context: dict) -> dict:
  59. """Führt den Befehl aus."""
  60. # Environment vorbereiten
  61. env = os.environ.copy()
  62. if self._env:
  63. env.update(self._env)
  64. # Kontext-Variablen als Env-Vars
  65. for key, value in context.items():
  66. if isinstance(value, (str, int, float, bool)):
  67. env[f"TRIXY_{key.upper()}"] = str(value)
  68. # Befehl ausführen
  69. if self._shell:
  70. cmd = self._command if isinstance(self._command, str) else " ".join(self._command)
  71. process = await asyncio.create_subprocess_shell(
  72. cmd,
  73. stdout=asyncio.subprocess.PIPE if self._capture_output else None,
  74. stderr=asyncio.subprocess.PIPE if self._capture_output else None,
  75. cwd=self._cwd,
  76. env=env,
  77. )
  78. else:
  79. cmd = self._command if isinstance(self._command, list) else shlex.split(self._command)
  80. process = await asyncio.create_subprocess_exec(
  81. *cmd,
  82. stdout=asyncio.subprocess.PIPE if self._capture_output else None,
  83. stderr=asyncio.subprocess.PIPE if self._capture_output else None,
  84. cwd=self._cwd,
  85. env=env,
  86. )
  87. try:
  88. stdout, stderr = await asyncio.wait_for(
  89. process.communicate(),
  90. timeout=self._timeout_seconds,
  91. )
  92. except asyncio.TimeoutError:
  93. process.kill()
  94. await process.wait()
  95. raise TimeoutError(f"Befehl-Timeout nach {self._timeout_seconds}s")
  96. # Ergebnis zusammenstellen
  97. result = {
  98. "return_code": process.returncode,
  99. "success": process.returncode == 0,
  100. }
  101. if self._capture_output:
  102. result["stdout"] = stdout.decode("utf-8", errors="replace") if stdout else ""
  103. result["stderr"] = stderr.decode("utf-8", errors="replace") if stderr else ""
  104. if process.returncode != 0:
  105. raise RuntimeError(
  106. f"Befehl fehlgeschlagen (code={process.returncode}): "
  107. f"{result.get('stderr', 'Kein Output')}"
  108. )
  109. return result
  110. def to_dict(self) -> dict:
  111. """Konvertiert zu Dictionary."""
  112. return {
  113. **self._base_dict(),
  114. "command": self._command,
  115. "cwd": self._cwd,
  116. "env": self._env,
  117. "shell": self._shell,
  118. "capture_output": self._capture_output,
  119. }
  120. @classmethod
  121. def from_dict(cls, data: dict) -> "CommandAction":
  122. """Erstellt Action aus Dictionary."""
  123. return cls(
  124. command=data["command"],
  125. cwd=data.get("cwd"),
  126. env=data.get("env"),
  127. shell=data.get("shell", False),
  128. capture_output=data.get("capture_output", True),
  129. name=data.get("name"),
  130. timeout_seconds=data.get("timeout_seconds", 60.0),
  131. )
  132. @classmethod
  133. def display_name(cls) -> str:
  134. """Anzeigename fuer die TUI."""
  135. return "Shell-Befehl"
  136. @classmethod
  137. def form_fields(cls) -> list[FormField]:
  138. """Gibt die Formular-Felder fuer die TUI zurueck."""
  139. return [
  140. FormField(
  141. name="command", label="Befehl", required=True,
  142. placeholder="echo 'Hello World'",
  143. help_text="Shell-Befehl oder Pfad",
  144. ),
  145. FormField(
  146. name="cwd", label="Arbeitsverzeichnis",
  147. placeholder="/home/user",
  148. ),
  149. FormField(
  150. name="shell", label="Shell verwenden",
  151. field_type=FieldType.CHECKBOX, default=False,
  152. help_text="Vorsicht: Nur fuer vertrauenswuerdige Eingaben",
  153. ),
  154. FormField(
  155. name="capture_output", label="Ausgabe erfassen",
  156. field_type=FieldType.CHECKBOX, default=True,
  157. ),
  158. FormField(
  159. name="timeout_seconds", label="Timeout (Sek.)",
  160. field_type=FieldType.NUMBER,
  161. default=60.0, min_value=1.0, is_float=True,
  162. ),
  163. ]
  164. @classmethod
  165. def validate_form(cls, data: dict) -> list[FormValidationError]:
  166. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  167. errors: list[FormValidationError] = []
  168. if not data.get("command", "").strip():
  169. errors.append(FormValidationError("command", "Befehl ist erforderlich"))
  170. return errors
  171. class ScriptAction(Action):
  172. """
  173. Action die ein Script ausführt.
  174. Beispiel:
  175. - ScriptAction("/path/to/script.py")
  176. - ScriptAction("backup.sh", interpreter="bash")
  177. """
  178. INTERPRETERS = {
  179. ".py": "python3",
  180. ".sh": "bash",
  181. ".bash": "bash",
  182. ".zsh": "zsh",
  183. ".rb": "ruby",
  184. ".pl": "perl",
  185. ".js": "node",
  186. }
  187. def __init__(
  188. self,
  189. script_path: str,
  190. args: list[str] | None = None,
  191. interpreter: str | None = None,
  192. cwd: str | None = None,
  193. env: dict[str, str] | None = None,
  194. name: str | None = None,
  195. timeout_seconds: float = 300.0,
  196. ):
  197. """
  198. Initialisiert die Action.
  199. Args:
  200. script_path: Pfad zum Script
  201. args: Argumente für das Script
  202. interpreter: Interpreter (wird aus Extension abgeleitet wenn None)
  203. cwd: Arbeitsverzeichnis
  204. env: Umgebungsvariablen
  205. name: Action-Name
  206. timeout_seconds: Timeout
  207. """
  208. script_name = os.path.basename(script_path)
  209. super().__init__(name or f"script_{script_name}", timeout_seconds)
  210. self._script_path = script_path
  211. self._args = args or []
  212. self._interpreter = interpreter
  213. self._cwd = cwd
  214. self._env = env
  215. @property
  216. def script_path(self) -> str:
  217. """Script-Pfad."""
  218. return self._script_path
  219. def _get_interpreter(self) -> str | None:
  220. """Ermittelt Interpreter aus Extension."""
  221. if self._interpreter:
  222. return self._interpreter
  223. _, ext = os.path.splitext(self._script_path)
  224. return self.INTERPRETERS.get(ext.lower())
  225. async def _execute(self, context: dict) -> dict:
  226. """Führt das Script aus."""
  227. # Prüfe ob Script existiert
  228. if not os.path.isfile(self._script_path):
  229. raise FileNotFoundError(f"Script nicht gefunden: {self._script_path}")
  230. # Befehl zusammenstellen
  231. interpreter = self._get_interpreter()
  232. if interpreter:
  233. cmd = [interpreter, self._script_path] + self._args
  234. else:
  235. # Script direkt ausführen (muss executable sein)
  236. cmd = [self._script_path] + self._args
  237. # CommandAction für Ausführung nutzen
  238. command_action = CommandAction(
  239. command=cmd,
  240. cwd=self._cwd or os.path.dirname(self._script_path),
  241. env=self._env,
  242. timeout_seconds=self._timeout_seconds,
  243. )
  244. return await command_action._execute(context)
  245. def to_dict(self) -> dict:
  246. """Konvertiert zu Dictionary."""
  247. return {
  248. **self._base_dict(),
  249. "script_path": self._script_path,
  250. "args": self._args,
  251. "interpreter": self._interpreter,
  252. "cwd": self._cwd,
  253. "env": self._env,
  254. }
  255. @classmethod
  256. def from_dict(cls, data: dict) -> "ScriptAction":
  257. """Erstellt Action aus Dictionary."""
  258. return cls(
  259. script_path=data["script_path"],
  260. args=data.get("args"),
  261. interpreter=data.get("interpreter"),
  262. cwd=data.get("cwd"),
  263. env=data.get("env"),
  264. name=data.get("name"),
  265. timeout_seconds=data.get("timeout_seconds", 300.0),
  266. )
  267. @classmethod
  268. def display_name(cls) -> str:
  269. """Anzeigename fuer die TUI."""
  270. return "Script ausfuehren"
  271. @classmethod
  272. def form_fields(cls) -> list[FormField]:
  273. """Gibt die Formular-Felder fuer die TUI zurueck."""
  274. return [
  275. FormField(
  276. name="script_path", label="Script-Pfad", required=True,
  277. placeholder="/path/to/script.py",
  278. ),
  279. FormField(
  280. name="args", label="Argumente",
  281. field_type=FieldType.TAGS,
  282. placeholder="--verbose, --output=/tmp",
  283. help_text="Komma-separierte Argumente",
  284. ),
  285. FormField(
  286. name="interpreter", label="Interpreter",
  287. placeholder="python3",
  288. help_text="Wird aus Dateiendung abgeleitet wenn leer",
  289. ),
  290. FormField(
  291. name="cwd", label="Arbeitsverzeichnis",
  292. ),
  293. FormField(
  294. name="timeout_seconds", label="Timeout (Sek.)",
  295. field_type=FieldType.NUMBER,
  296. default=300.0, min_value=1.0, is_float=True,
  297. ),
  298. ]
  299. @classmethod
  300. def validate_form(cls, data: dict) -> list[FormValidationError]:
  301. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  302. errors: list[FormValidationError] = []
  303. if not data.get("script_path", "").strip():
  304. errors.append(FormValidationError("script_path", "Script-Pfad ist erforderlich"))
  305. return errors