mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 21:41:57 -06:00
Merge pull request #50 from bunny-lab-io:codex/expose-environment-variables-in-job-screens
Expose assembly variable overrides in job runs
This commit is contained in:
@@ -7,6 +7,7 @@ import time
|
|||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -39,6 +40,39 @@ def _project_root():
|
|||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
cleaned = ''.join(stripped.split())
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_playbook_content(raw_content, encoding_hint):
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
encoding = str(encoding_hint or '').strip().lower()
|
||||||
|
if encoding in ('base64', 'b64', 'base-64'):
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
return raw_content
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _agent_root():
|
def _agent_root():
|
||||||
# Resolve Agent root at runtime.
|
# Resolve Agent root at runtime.
|
||||||
# Typical runtime: <ProjectRoot>/Agent/Borealis/Roles/<this_file>
|
# Typical runtime: <ProjectRoot>/Agent/Borealis/Roles/<this_file>
|
||||||
@@ -801,7 +835,7 @@ try {{
|
|||||||
return
|
return
|
||||||
# Accept provided run_id or generate one
|
# Accept provided run_id or generate one
|
||||||
run_id = (payload.get('run_id') or '').strip() or uuid.uuid4().hex
|
run_id = (payload.get('run_id') or '').strip() or uuid.uuid4().hex
|
||||||
content = payload.get('playbook_content') or ''
|
content = _decode_playbook_content(payload.get('playbook_content'), payload.get('playbook_encoding'))
|
||||||
p_name = payload.get('playbook_name') or ''
|
p_name = payload.get('playbook_name') or ''
|
||||||
act_id = payload.get('activity_job_id')
|
act_id = payload.get('activity_job_id')
|
||||||
sched_job_id = payload.get('scheduled_job_id')
|
sched_job_id = payload.get('scheduled_job_id')
|
||||||
@@ -874,7 +908,7 @@ try {{
|
|||||||
if target and target != hostname.lower():
|
if target and target != hostname.lower():
|
||||||
return
|
return
|
||||||
run_id = uuid.uuid4().hex
|
run_id = uuid.uuid4().hex
|
||||||
content = payload.get('script_content') or ''
|
content = _decode_playbook_content(payload.get('script_content'), payload.get('script_encoding'))
|
||||||
p_name = payload.get('script_name') or ''
|
p_name = payload.get('script_name') or ''
|
||||||
self._runs[run_id] = {'cancel': False, 'proc': None}
|
self._runs[run_id] = {'cancel': False, 'proc': None}
|
||||||
asyncio.create_task(self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=payload.get('job_id'), connection='local'))
|
asyncio.create_task(self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=payload.get('job_id'), connection='local'))
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import re
|
|||||||
import asyncio
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, List
|
import base64
|
||||||
|
from typing import Dict, List, Optional
|
||||||
from PyQt5 import QtWidgets, QtGui
|
from PyQt5 import QtWidgets, QtGui
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,11 @@ ROLE_CONTEXTS = ['interactive']
|
|||||||
IS_WINDOWS = os.name == 'nt'
|
IS_WINDOWS = os.name == 'nt'
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_env_key(name: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
|
||||||
|
return cleaned.upper()
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_env_map(raw) -> Dict[str, str]:
|
def _sanitize_env_map(raw) -> Dict[str, str]:
|
||||||
env: Dict[str, str] = {}
|
env: Dict[str, str] = {}
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
@@ -24,7 +30,7 @@ def _sanitize_env_map(raw) -> Dict[str, str]:
|
|||||||
name = str(key).strip()
|
name = str(key).strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
|
env_key = _canonical_env_key(name)
|
||||||
if not env_key:
|
if not env_key:
|
||||||
continue
|
continue
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
@@ -37,19 +43,99 @@ def _sanitize_env_map(raw) -> Dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, str]]) -> Dict[str, str]:
|
||||||
|
if not isinstance(env_map, dict) or not isinstance(variables, list):
|
||||||
|
return env_map
|
||||||
|
for var in variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical or canonical not in env_map:
|
||||||
|
continue
|
||||||
|
value = env_map[canonical]
|
||||||
|
alias = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||||
|
if alias and alias not in env_map:
|
||||||
|
env_map[alias] = value
|
||||||
|
if alias == name:
|
||||||
|
continue
|
||||||
|
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in env_map:
|
||||||
|
env_map[name] = value
|
||||||
|
return env_map
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value: str) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cleaned = re.sub(r"\s+", "", stripped)
|
||||||
|
except Exception:
|
||||||
|
cleaned = stripped
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_content(raw_content, encoding_hint) -> str:
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
encoding = str(encoding_hint or "").strip().lower()
|
||||||
|
if encoding in ("base64", "b64", "base-64"):
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
return raw_content
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _ps_literal(value: str) -> str:
|
def _ps_literal(value: str) -> str:
|
||||||
return "'" + value.replace("'", "''") + "'"
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
|
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
|
||||||
|
def _env_assignment_lines(lines: List[str]) -> None:
|
||||||
|
for key, value in (env_map or {}).items():
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
value_literal = _ps_literal(value)
|
||||||
|
key_literal = _ps_literal(key)
|
||||||
|
env_path_literal = f"[string]::Format('Env:{{0}}', {key_literal})"
|
||||||
|
lines.append(
|
||||||
|
f"try {{ [System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process') }} catch {{}}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"try { Set-Item -LiteralPath (" + env_path_literal + ") -Value " + value_literal +
|
||||||
|
" -ErrorAction Stop } catch { try { New-Item -Path (" + env_path_literal + ") -Value " +
|
||||||
|
value_literal + " -Force | Out-Null } catch {} }"
|
||||||
|
)
|
||||||
|
|
||||||
|
prelude_lines: List[str] = []
|
||||||
|
_env_assignment_lines(prelude_lines)
|
||||||
|
|
||||||
inner_lines: List[str] = []
|
inner_lines: List[str] = []
|
||||||
for key, value in (env_map or {}).items():
|
_env_assignment_lines(inner_lines)
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
inner_lines.append(f"$Env:{key} = {_ps_literal(value)}")
|
|
||||||
inner_lines.append(content or "")
|
inner_lines.append(content or "")
|
||||||
|
|
||||||
|
prelude = "\n".join(prelude_lines)
|
||||||
inner = "\n".join(line for line in inner_lines if line is not None)
|
inner = "\n".join(line for line in inner_lines if line is not None)
|
||||||
script_block = "$__BorealisScript = {\n" + inner + "\n}\n"
|
|
||||||
|
pieces: List[str] = []
|
||||||
|
if prelude:
|
||||||
|
pieces.append(prelude)
|
||||||
|
pieces.append("$__BorealisScript = {\n" + inner + "\n}\n")
|
||||||
|
script_block = "\n".join(pieces)
|
||||||
if timeout_seconds and timeout_seconds > 0:
|
if timeout_seconds and timeout_seconds > 0:
|
||||||
block = (
|
block = (
|
||||||
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
|
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
|
||||||
@@ -191,7 +277,7 @@ class Role:
|
|||||||
job_id = payload.get('job_id')
|
job_id = payload.get('job_id')
|
||||||
script_type = (payload.get('script_type') or '').lower()
|
script_type = (payload.get('script_type') or '').lower()
|
||||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||||
content = payload.get('script_content') or ''
|
content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding'))
|
||||||
raw_env = payload.get('environment')
|
raw_env = payload.get('environment')
|
||||||
env_map = _sanitize_env_map(raw_env)
|
env_map = _sanitize_env_map(raw_env)
|
||||||
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
|
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
|
||||||
@@ -201,7 +287,7 @@ class Role:
|
|||||||
name = str(var.get('name') or '').strip()
|
name = str(var.get('name') or '').strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
|
key = _canonical_env_key(name)
|
||||||
if key in env_map:
|
if key in env_map:
|
||||||
continue
|
continue
|
||||||
default_val = var.get('default')
|
default_val = var.get('default')
|
||||||
@@ -211,6 +297,7 @@ class Role:
|
|||||||
env_map[key] = ""
|
env_map[key] = ""
|
||||||
else:
|
else:
|
||||||
env_map[key] = str(default_val)
|
env_map[key] = str(default_val)
|
||||||
|
env_map = _apply_variable_aliases(env_map, variables)
|
||||||
try:
|
try:
|
||||||
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
|
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Dict, List
|
import base64
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
ROLE_NAME = 'script_exec_system'
|
ROLE_NAME = 'script_exec_system'
|
||||||
@@ -16,6 +17,11 @@ def _project_root():
|
|||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_env_key(name: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
|
||||||
|
return cleaned.upper()
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_env_map(raw) -> Dict[str, str]:
|
def _sanitize_env_map(raw) -> Dict[str, str]:
|
||||||
env: Dict[str, str] = {}
|
env: Dict[str, str] = {}
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
@@ -25,7 +31,7 @@ def _sanitize_env_map(raw) -> Dict[str, str]:
|
|||||||
name = str(key).strip()
|
name = str(key).strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
|
env_key = _canonical_env_key(name)
|
||||||
if not env_key:
|
if not env_key:
|
||||||
continue
|
continue
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
@@ -38,19 +44,100 @@ def _sanitize_env_map(raw) -> Dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, str]]) -> Dict[str, str]:
|
||||||
|
if not isinstance(env_map, dict) or not isinstance(variables, list):
|
||||||
|
return env_map
|
||||||
|
for var in variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical or canonical not in env_map:
|
||||||
|
continue
|
||||||
|
value = env_map[canonical]
|
||||||
|
alias = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||||
|
if alias and alias not in env_map:
|
||||||
|
env_map[alias] = value
|
||||||
|
if alias == name:
|
||||||
|
continue
|
||||||
|
# Only add the original name when it results in a valid identifier.
|
||||||
|
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in env_map:
|
||||||
|
env_map[name] = value
|
||||||
|
return env_map
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value: str) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cleaned = re.sub(r"\s+", "", stripped)
|
||||||
|
except Exception:
|
||||||
|
cleaned = stripped
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_content(raw_content, encoding_hint) -> str:
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
encoding = str(encoding_hint or "").strip().lower()
|
||||||
|
if encoding in ("base64", "b64", "base-64"):
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
decoded = _decode_base64_text(raw_content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
return raw_content
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _ps_literal(value: str) -> str:
|
def _ps_literal(value: str) -> str:
|
||||||
return "'" + value.replace("'", "''") + "'"
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
|
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
|
||||||
|
def _env_assignment_lines(lines: List[str]) -> None:
|
||||||
|
for key, value in (env_map or {}).items():
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
value_literal = _ps_literal(value)
|
||||||
|
key_literal = _ps_literal(key)
|
||||||
|
env_path_literal = f"[string]::Format('Env:{{0}}', {key_literal})"
|
||||||
|
lines.append(
|
||||||
|
f"try {{ [System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process') }} catch {{}}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"try { Set-Item -LiteralPath (" + env_path_literal + ") -Value " + value_literal +
|
||||||
|
" -ErrorAction Stop } catch { try { New-Item -Path (" + env_path_literal + ") -Value " +
|
||||||
|
value_literal + " -Force | Out-Null } catch {} }"
|
||||||
|
)
|
||||||
|
|
||||||
|
prelude_lines: List[str] = []
|
||||||
|
_env_assignment_lines(prelude_lines)
|
||||||
|
|
||||||
inner_lines: List[str] = []
|
inner_lines: List[str] = []
|
||||||
for key, value in (env_map or {}).items():
|
_env_assignment_lines(inner_lines)
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
inner_lines.append(f"$Env:{key} = {_ps_literal(value)}")
|
|
||||||
inner_lines.append(content or "")
|
inner_lines.append(content or "")
|
||||||
|
|
||||||
|
prelude = "\n".join(prelude_lines)
|
||||||
inner = "\n".join(line for line in inner_lines if line is not None)
|
inner = "\n".join(line for line in inner_lines if line is not None)
|
||||||
script_block = "$__BorealisScript = {\n" + inner + "\n}\n"
|
|
||||||
|
pieces: List[str] = []
|
||||||
|
if prelude:
|
||||||
|
pieces.append(prelude)
|
||||||
|
pieces.append("$__BorealisScript = {\n" + inner + "\n}\n")
|
||||||
|
script_block = "\n".join(pieces)
|
||||||
if timeout_seconds and timeout_seconds > 0:
|
if timeout_seconds and timeout_seconds > 0:
|
||||||
block = (
|
block = (
|
||||||
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
|
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
|
||||||
@@ -184,7 +271,7 @@ class Role:
|
|||||||
return
|
return
|
||||||
job_id = payload.get('job_id')
|
job_id = payload.get('job_id')
|
||||||
script_type = (payload.get('script_type') or '').lower()
|
script_type = (payload.get('script_type') or '').lower()
|
||||||
content = payload.get('script_content') or ''
|
content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding'))
|
||||||
raw_env = payload.get('environment')
|
raw_env = payload.get('environment')
|
||||||
env_map = _sanitize_env_map(raw_env)
|
env_map = _sanitize_env_map(raw_env)
|
||||||
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
|
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
|
||||||
@@ -194,7 +281,7 @@ class Role:
|
|||||||
name = str(var.get('name') or '').strip()
|
name = str(var.get('name') or '').strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
|
key = _canonical_env_key(name)
|
||||||
if key in env_map:
|
if key in env_map:
|
||||||
continue
|
continue
|
||||||
default_val = var.get('default')
|
default_val = var.get('default')
|
||||||
@@ -204,6 +291,7 @@ class Role:
|
|||||||
env_map[key] = ""
|
env_map[key] = ""
|
||||||
else:
|
else:
|
||||||
env_map[key] = str(default_val)
|
env_map[key] = str(default_val)
|
||||||
|
env_map = _apply_variable_aliases(env_map, variables)
|
||||||
try:
|
try:
|
||||||
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
|
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -156,6 +156,39 @@ def _log_agent(message: str, fname: str = 'agent.log'):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
cleaned = ''.join(stripped.split())
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_payload(content, encoding_hint):
|
||||||
|
if isinstance(content, str):
|
||||||
|
encoding = str(encoding_hint or '').strip().lower()
|
||||||
|
if encoding in ('base64', 'b64', 'base-64'):
|
||||||
|
decoded = _decode_base64_text(content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
decoded = _decode_base64_text(content)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded
|
||||||
|
return content
|
||||||
|
return ''
|
||||||
|
|
||||||
def _resolve_config_path():
|
def _resolve_config_path():
|
||||||
"""
|
"""
|
||||||
Resolve the path for agent settings json in the centralized location:
|
Resolve the path for agent settings json in the centralized location:
|
||||||
@@ -1520,7 +1553,7 @@ if __name__=='__main__':
|
|||||||
return
|
return
|
||||||
job_id = payload.get('job_id')
|
job_id = payload.get('job_id')
|
||||||
script_type = (payload.get('script_type') or '').lower()
|
script_type = (payload.get('script_type') or '').lower()
|
||||||
content = payload.get('script_content') or ''
|
content = _decode_script_payload(payload.get('script_content'), payload.get('script_encoding'))
|
||||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||||
if script_type != 'powershell':
|
if script_type != 'powershell':
|
||||||
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })
|
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })
|
||||||
|
|||||||
@@ -198,6 +198,61 @@ function normalizeVariablesFromServer(vars = []) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeBase64String(data = "") {
|
||||||
|
if (typeof data !== "string") {
|
||||||
|
return { success: false, value: "" };
|
||||||
|
}
|
||||||
|
if (!data.trim()) {
|
||||||
|
return { success: true, value: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
||||||
|
const binary = window.atob(data);
|
||||||
|
if (typeof TextDecoder !== "undefined") {
|
||||||
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
return { success: true, value: decoder.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))) };
|
||||||
|
}
|
||||||
|
return { success: true, value: binary };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// fall through to Buffer fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return { success: true, value: Buffer.from(data, "base64").toString("utf-8") };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { success: false, value: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64String(text = "") {
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
text = text == null ? "" : String(text);
|
||||||
|
}
|
||||||
|
if (!text) return "";
|
||||||
|
try {
|
||||||
|
if (typeof TextEncoder !== "undefined" && typeof window !== "undefined" && typeof window.btoa === "function") {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(text);
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((b) => { binary += String.fromCharCode(b); });
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// fall through to Buffer fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return Buffer.from(text, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFilesFromServer(files = []) {
|
function normalizeFilesFromServer(files = []) {
|
||||||
return (Array.isArray(files) ? files : []).map((f, idx) => ({
|
return (Array.isArray(files) ? files : []).map((f, idx) => ({
|
||||||
id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
@@ -219,7 +274,20 @@ function fromServerDocument(doc = {}, defaultType = "powershell") {
|
|||||||
? doc.script_lines.map((line) => (line == null ? "" : String(line))).join("\n")
|
? doc.script_lines.map((line) => (line == null ? "" : String(line))).join("\n")
|
||||||
: "";
|
: "";
|
||||||
const script = doc.script ?? doc.content ?? legacyScript;
|
const script = doc.script ?? doc.content ?? legacyScript;
|
||||||
assembly.script = typeof script === "string" ? script : legacyScript;
|
if (typeof script === "string") {
|
||||||
|
const encoding = (doc.script_encoding || doc.scriptEncoding || "").toLowerCase();
|
||||||
|
if (["base64", "b64", "base-64"].includes(encoding)) {
|
||||||
|
const decoded = decodeBase64String(script);
|
||||||
|
assembly.script = decoded.success ? decoded.value : "";
|
||||||
|
} else if (!encoding) {
|
||||||
|
const decoded = decodeBase64String(script);
|
||||||
|
assembly.script = decoded.success ? decoded.value : script;
|
||||||
|
} else {
|
||||||
|
assembly.script = script;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assembly.script = legacyScript;
|
||||||
|
}
|
||||||
const timeout = doc.timeout_seconds ?? doc.timeout ?? assembly.timeoutSeconds;
|
const timeout = doc.timeout_seconds ?? doc.timeout ?? assembly.timeoutSeconds;
|
||||||
assembly.timeoutSeconds = Number.isFinite(Number(timeout))
|
assembly.timeoutSeconds = Number.isFinite(Number(timeout))
|
||||||
? Number(timeout)
|
? Number(timeout)
|
||||||
@@ -241,13 +309,15 @@ function toServerDocument(assembly) {
|
|||||||
: "";
|
: "";
|
||||||
const timeoutNumeric = Number(assembly.timeoutSeconds);
|
const timeoutNumeric = Number(assembly.timeoutSeconds);
|
||||||
const timeoutSeconds = Number.isFinite(timeoutNumeric) ? Math.max(0, Math.round(timeoutNumeric)) : 3600;
|
const timeoutSeconds = Number.isFinite(timeoutNumeric) ? Math.max(0, Math.round(timeoutNumeric)) : 3600;
|
||||||
|
const encodedScript = encodeBase64String(normalizedScript);
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
name: assembly.name?.trim() || "",
|
name: assembly.name?.trim() || "",
|
||||||
description: assembly.description || "",
|
description: assembly.description || "",
|
||||||
category: assembly.category || "script",
|
category: assembly.category || "script",
|
||||||
type: assembly.type || "powershell",
|
type: assembly.type || "powershell",
|
||||||
script: normalizedScript,
|
script: encodedScript,
|
||||||
|
script_encoding: "base64",
|
||||||
timeout_seconds: timeoutSeconds,
|
timeout_seconds: timeoutSeconds,
|
||||||
sites: {
|
sites: {
|
||||||
mode: assembly.sites?.mode === "specific" ? "specific" : "all",
|
mode: assembly.sites?.mode === "specific" ? "specific" : "all",
|
||||||
|
|||||||
@@ -124,7 +124,126 @@ function buildWorkflowTree(workflows, folders) {
|
|||||||
return { root: [rootNode], map };
|
return { root: [rootNode], map };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentCard({ comp, onRemove }) {
|
function normalizeVariableDefinitions(vars = []) {
|
||||||
|
return (Array.isArray(vars) ? vars : [])
|
||||||
|
.map((raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
|
||||||
|
if (!name) return null;
|
||||||
|
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
|
||||||
|
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
|
||||||
|
const required = Boolean(raw.required);
|
||||||
|
const description = typeof raw.description === "string" ? raw.description : "";
|
||||||
|
let defaultValue = "";
|
||||||
|
if (Object.prototype.hasOwnProperty.call(raw, "default")) defaultValue = raw.default;
|
||||||
|
else if (Object.prototype.hasOwnProperty.call(raw, "defaultValue")) defaultValue = raw.defaultValue;
|
||||||
|
else if (Object.prototype.hasOwnProperty.call(raw, "default_value")) defaultValue = raw.default_value;
|
||||||
|
return { name, label, type, required, description, default: defaultValue };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceVariableValue(type, value) {
|
||||||
|
if (type === "boolean") {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "number") return value !== 0;
|
||||||
|
if (value == null) return false;
|
||||||
|
const str = String(value).trim().toLowerCase();
|
||||||
|
if (!str) return false;
|
||||||
|
return ["true", "1", "yes", "on"].includes(str);
|
||||||
|
}
|
||||||
|
if (type === "number") {
|
||||||
|
if (value == null || value === "") return "";
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? String(parsed) : "";
|
||||||
|
}
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeComponentVariables(docVars = [], storedVars = [], storedValueMap = {}) {
|
||||||
|
const definitions = normalizeVariableDefinitions(docVars);
|
||||||
|
const overrides = {};
|
||||||
|
const storedMeta = {};
|
||||||
|
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return;
|
||||||
|
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||||
|
if (!name) return;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(raw, "value")) overrides[name] = raw.value;
|
||||||
|
else if (Object.prototype.hasOwnProperty.call(raw, "default")) overrides[name] = raw.default;
|
||||||
|
storedMeta[name] = {
|
||||||
|
label: typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name,
|
||||||
|
type: typeof raw.type === "string" ? raw.type.toLowerCase() : undefined,
|
||||||
|
required: Boolean(raw.required),
|
||||||
|
description: typeof raw.description === "string" ? raw.description : "",
|
||||||
|
default: Object.prototype.hasOwnProperty.call(raw, "default") ? raw.default : ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (storedValueMap && typeof storedValueMap === "object") {
|
||||||
|
Object.entries(storedValueMap).forEach(([key, val]) => {
|
||||||
|
const name = typeof key === "string" ? key.trim() : "";
|
||||||
|
if (name) overrides[name] = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = new Set();
|
||||||
|
const merged = definitions.map((def) => {
|
||||||
|
const override = Object.prototype.hasOwnProperty.call(overrides, def.name) ? overrides[def.name] : undefined;
|
||||||
|
used.add(def.name);
|
||||||
|
return {
|
||||||
|
...def,
|
||||||
|
value: override !== undefined ? coerceVariableValue(def.type, override) : coerceVariableValue(def.type, def.default)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return;
|
||||||
|
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||||
|
if (!name || used.has(name)) return;
|
||||||
|
const meta = storedMeta[name] || {};
|
||||||
|
const type = meta.type || (typeof overrides[name] === "boolean" ? "boolean" : typeof overrides[name] === "number" ? "number" : "string");
|
||||||
|
const defaultValue = Object.prototype.hasOwnProperty.call(meta, "default") ? meta.default : "";
|
||||||
|
const override = Object.prototype.hasOwnProperty.call(overrides, name)
|
||||||
|
? overrides[name]
|
||||||
|
: Object.prototype.hasOwnProperty.call(raw, "value")
|
||||||
|
? raw.value
|
||||||
|
: defaultValue;
|
||||||
|
merged.push({
|
||||||
|
name,
|
||||||
|
label: meta.label || name,
|
||||||
|
type,
|
||||||
|
required: Boolean(meta.required),
|
||||||
|
description: meta.description || "",
|
||||||
|
default: defaultValue,
|
||||||
|
value: coerceVariableValue(type, override)
|
||||||
|
});
|
||||||
|
used.add(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(overrides).forEach(([nameRaw, val]) => {
|
||||||
|
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
|
||||||
|
if (!name || used.has(name)) return;
|
||||||
|
const type = typeof val === "boolean" ? "boolean" : typeof val === "number" ? "number" : "string";
|
||||||
|
merged.push({
|
||||||
|
name,
|
||||||
|
label: name,
|
||||||
|
type,
|
||||||
|
required: false,
|
||||||
|
description: "",
|
||||||
|
default: "",
|
||||||
|
value: coerceVariableValue(type, val)
|
||||||
|
});
|
||||||
|
used.add(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
|
||||||
|
const variables = Array.isArray(comp.variables)
|
||||||
|
? comp.variables.filter((v) => v && typeof v.name === "string" && v.name)
|
||||||
|
: [];
|
||||||
|
const description = comp.description || comp.path || "";
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ bgcolor: "#2a2a2a", border: "1px solid #3a3a3a", p: 1.2, mb: 1.2, borderRadius: 1 }}>
|
<Paper sx={{ bgcolor: "#2a2a2a", border: "1px solid #3a3a3a", p: 1.2, mb: 1.2, borderRadius: 1 }}>
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
@@ -133,30 +252,65 @@ function ComponentCard({ comp, onRemove }) {
|
|||||||
{comp.type === "script" ? comp.name : comp.name}
|
{comp.type === "script" ? comp.name : comp.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
{comp.description || (comp.type === "script" ? comp.path : comp.path)}
|
{description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider orientation="vertical" flexItem sx={{ borderColor: "#333" }} />
|
<Divider orientation="vertical" flexItem sx={{ borderColor: "#333" }} />
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables (placeholder)</Typography>
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
||||||
<FormControlLabel control={<Checkbox size="small" />} label={<Typography variant="body2">Example toggle</Typography>} />
|
{variables.length ? (
|
||||||
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
<TextField size="small" label="Param A" variant="outlined" fullWidth
|
{variables.map((variable) => (
|
||||||
InputLabelProps={{ shrink: true }}
|
<Box key={variable.name}>
|
||||||
sx={{
|
{variable.type === "boolean" ? (
|
||||||
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b" },
|
<>
|
||||||
"& .MuiInputLabel-root": { color: "#aaa" },
|
<FormControlLabel
|
||||||
"& .MuiInputBase-input": { color: "#e6edf3" }
|
control={(
|
||||||
}}
|
<Checkbox
|
||||||
/>
|
size="small"
|
||||||
<Select size="small" value={"install"} sx={{ minWidth: 160 }}>
|
checked={Boolean(variable.value)}
|
||||||
<MenuItem value="install">Install/Update existing installation</MenuItem>
|
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.checked)}
|
||||||
<MenuItem value="uninstall">Uninstall</MenuItem>
|
/>
|
||||||
</Select>
|
)}
|
||||||
</Box>
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
{variable.label}
|
||||||
|
{variable.required ? " *" : ""}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{variable.description ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
||||||
|
{variable.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label={`${variable.label}${variable.required ? " *" : ""}`}
|
||||||
|
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
||||||
|
value={variable.value ?? ""}
|
||||||
|
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
|
||||||
|
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||||
|
}}
|
||||||
|
error={Boolean(errors[variable.name])}
|
||||||
|
helperText={errors[variable.name] || variable.description || ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton onClick={onRemove} size="small" sx={{ color: "#ff6666" }}>
|
<IconButton onClick={() => onRemove(comp.localId)} size="small" sx={{ color: "#ff6666" }}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -189,6 +343,143 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}]
|
const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}]
|
||||||
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
||||||
const [deviceSearch, setDeviceSearch] = useState("");
|
const [deviceSearch, setDeviceSearch] = useState("");
|
||||||
|
const [componentVarErrors, setComponentVarErrors] = useState({});
|
||||||
|
|
||||||
|
const generateLocalId = useCallback(
|
||||||
|
() => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizeComponentPath = useCallback((type, rawPath) => {
|
||||||
|
const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (type === "script") {
|
||||||
|
return trimmed.startsWith("Scripts/") ? trimmed : `Scripts/${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAssemblyDoc = useCallback(async (type, rawPath) => {
|
||||||
|
const normalizedPath = normalizeComponentPath(type, rawPath);
|
||||||
|
if (!normalizedPath) return { doc: null, normalizedPath: "" };
|
||||||
|
const trimmed = normalizedPath.replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
||||||
|
if (!trimmed) return { doc: null, normalizedPath: "" };
|
||||||
|
let requestPath = trimmed;
|
||||||
|
if (type === "script" && requestPath.toLowerCase().startsWith("scripts/")) {
|
||||||
|
requestPath = requestPath.slice("Scripts/".length);
|
||||||
|
} else if (type === "ansible" && requestPath.toLowerCase().startsWith("ansible_playbooks/")) {
|
||||||
|
requestPath = requestPath.slice("Ansible_Playbooks/".length);
|
||||||
|
}
|
||||||
|
if (!requestPath) return { doc: null, normalizedPath };
|
||||||
|
try {
|
||||||
|
const island = type === "ansible" ? "ansible" : "scripts";
|
||||||
|
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(requestPath)}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { doc: null, normalizedPath };
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
return { doc: data, normalizedPath };
|
||||||
|
} catch {
|
||||||
|
return { doc: null, normalizedPath };
|
||||||
|
}
|
||||||
|
}, [normalizeComponentPath]);
|
||||||
|
|
||||||
|
const hydrateExistingComponents = useCallback(async (rawComponents = []) => {
|
||||||
|
const results = [];
|
||||||
|
for (const raw of rawComponents) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
const typeRaw = raw.type || raw.component_type || "script";
|
||||||
|
if (typeRaw === "workflow") {
|
||||||
|
results.push({
|
||||||
|
...raw,
|
||||||
|
type: "workflow",
|
||||||
|
variables: Array.isArray(raw.variables) ? raw.variables : [],
|
||||||
|
localId: generateLocalId()
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = typeRaw === "ansible" ? "ansible" : "script";
|
||||||
|
const basePath = raw.path || raw.script_path || raw.rel_path || "";
|
||||||
|
const { doc, normalizedPath } = await fetchAssemblyDoc(type, basePath);
|
||||||
|
const assembly = doc?.assembly || {};
|
||||||
|
const docVars = assembly?.variables || doc?.variables || [];
|
||||||
|
const mergedVariables = mergeComponentVariables(docVars, raw.variables, raw.variable_values);
|
||||||
|
results.push({
|
||||||
|
...raw,
|
||||||
|
type,
|
||||||
|
path: normalizedPath || basePath,
|
||||||
|
name: raw.name || assembly?.name || raw.file_name || raw.tab_name || normalizedPath || basePath,
|
||||||
|
description: raw.description || assembly?.description || normalizedPath || basePath,
|
||||||
|
variables: mergedVariables,
|
||||||
|
localId: generateLocalId()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}, [fetchAssemblyDoc, generateLocalId]);
|
||||||
|
|
||||||
|
const sanitizeComponentsForSave = useCallback((items) => {
|
||||||
|
return (Array.isArray(items) ? items : []).map((comp) => {
|
||||||
|
if (!comp || typeof comp !== "object") return comp;
|
||||||
|
const { localId, ...rest } = comp;
|
||||||
|
const sanitized = { ...rest };
|
||||||
|
if (Array.isArray(comp.variables)) {
|
||||||
|
const valuesMap = {};
|
||||||
|
sanitized.variables = comp.variables
|
||||||
|
.filter((v) => v && typeof v.name === "string" && v.name)
|
||||||
|
.map((v) => {
|
||||||
|
const entry = {
|
||||||
|
name: v.name,
|
||||||
|
label: v.label || v.name,
|
||||||
|
type: v.type || "string",
|
||||||
|
required: Boolean(v.required),
|
||||||
|
description: v.description || ""
|
||||||
|
};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(v, "default")) entry.default = v.default;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(v, "value")) {
|
||||||
|
entry.value = v.value;
|
||||||
|
valuesMap[v.name] = v.value;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
if (!sanitized.variables.length) sanitized.variables = [];
|
||||||
|
if (Object.keys(valuesMap).length) sanitized.variable_values = valuesMap;
|
||||||
|
else delete sanitized.variable_values;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateComponentVariable = useCallback((localId, name, value) => {
|
||||||
|
if (!localId || !name) return;
|
||||||
|
setComponents((prev) => prev.map((comp) => {
|
||||||
|
if (!comp || comp.localId !== localId) return comp;
|
||||||
|
const vars = Array.isArray(comp.variables) ? comp.variables : [];
|
||||||
|
const nextVars = vars.map((variable) => {
|
||||||
|
if (!variable || variable.name !== name) return variable;
|
||||||
|
return { ...variable, value: coerceVariableValue(variable.type || "string", value) };
|
||||||
|
});
|
||||||
|
return { ...comp, variables: nextVars };
|
||||||
|
}));
|
||||||
|
setComponentVarErrors((prev) => {
|
||||||
|
if (!prev[localId] || !prev[localId][name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
const compErrors = { ...next[localId] };
|
||||||
|
delete compErrors[name];
|
||||||
|
if (Object.keys(compErrors).length) next[localId] = compErrors;
|
||||||
|
else delete next[localId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeComponent = useCallback((localId) => {
|
||||||
|
setComponents((prev) => prev.filter((comp) => comp.localId !== localId));
|
||||||
|
setComponentVarErrors((prev) => {
|
||||||
|
if (!prev[localId]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[localId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
|
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
|
||||||
@@ -355,18 +646,32 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
|
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialJob && initialJob.id) {
|
let canceled = false;
|
||||||
setJobName(initialJob.name || "");
|
const hydrate = async () => {
|
||||||
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
if (initialJob && initialJob.id) {
|
||||||
setComponents(comps.length ? comps : []);
|
setJobName(initialJob.name || "");
|
||||||
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
||||||
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
||||||
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
|
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
|
||||||
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
||||||
setExpiration(initialJob.expiration || "no_expire");
|
setExpiration(initialJob.expiration || "no_expire");
|
||||||
setExecContext(initialJob.execution_context || "system");
|
setExecContext(initialJob.execution_context || "system");
|
||||||
}
|
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
||||||
}, [initialJob]);
|
const hydrated = await hydrateExistingComponents(comps);
|
||||||
|
if (!canceled) {
|
||||||
|
setComponents(hydrated);
|
||||||
|
setComponentVarErrors({});
|
||||||
|
}
|
||||||
|
} else if (!initialJob) {
|
||||||
|
setComponents([]);
|
||||||
|
setComponentVarErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
hydrate();
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [initialJob, hydrateExistingComponents]);
|
||||||
|
|
||||||
const openAddComponent = async () => {
|
const openAddComponent = async () => {
|
||||||
setAddCompOpen(true);
|
setAddCompOpen(true);
|
||||||
@@ -399,32 +704,38 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
} catch { setAnsibleTree([]); setAnsibleMap({}); }
|
} catch { setAnsibleTree([]); setAnsibleMap({}); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSelectedComponent = () => {
|
const addSelectedComponent = useCallback(async () => {
|
||||||
const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap);
|
const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap);
|
||||||
const node = map[selectedNodeId];
|
const node = map[selectedNodeId];
|
||||||
if (!node || node.isFolder) return false;
|
if (!node || node.isFolder) return false;
|
||||||
if (compTab === "scripts" && node.script) {
|
if (compTab === "workflows" && node.workflow) {
|
||||||
setComponents((prev) => [
|
|
||||||
...prev,
|
|
||||||
// Store path relative to Assemblies root with 'Scripts/' prefix for scheduler compatibility
|
|
||||||
{ type: "script", path: (node.path.startsWith('Scripts/') ? node.path : `Scripts/${node.path}`), name: node.fileName || node.label, description: node.path }
|
|
||||||
]);
|
|
||||||
setSelectedNodeId("");
|
|
||||||
return true;
|
|
||||||
} else if (compTab === "ansible" && node.script) {
|
|
||||||
setComponents((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: "ansible", path: node.path, name: node.fileName || node.label, description: node.path }
|
|
||||||
]);
|
|
||||||
setSelectedNodeId("");
|
|
||||||
return true;
|
|
||||||
} else if (compTab === "workflows" && node.workflow) {
|
|
||||||
alert("Workflows within Scheduled Jobs are not supported yet");
|
alert("Workflows within Scheduled Jobs are not supported yet");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (compTab === "scripts" || compTab === "ansible") {
|
||||||
|
const type = compTab === "scripts" ? "script" : "ansible";
|
||||||
|
const rawPath = node.path || node.id || "";
|
||||||
|
const { doc, normalizedPath } = await fetchAssemblyDoc(type, rawPath);
|
||||||
|
const assembly = doc?.assembly || {};
|
||||||
|
const docVars = assembly?.variables || doc?.variables || [];
|
||||||
|
const mergedVars = mergeComponentVariables(docVars, [], {});
|
||||||
|
setComponents((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
path: normalizedPath || rawPath,
|
||||||
|
name: assembly?.name || node.fileName || node.label,
|
||||||
|
description: assembly?.description || normalizedPath || rawPath,
|
||||||
|
variables: mergedVars,
|
||||||
|
localId: generateLocalId()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
setSelectedNodeId("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
setSelectedNodeId("");
|
setSelectedNodeId("");
|
||||||
return false;
|
return false;
|
||||||
};
|
}, [compTab, scriptMap, ansibleMap, workflowMap, selectedNodeId, fetchAssemblyDoc, generateLocalId]);
|
||||||
|
|
||||||
const openAddTargets = async () => {
|
const openAddTargets = async () => {
|
||||||
setAddTargetOpen(true);
|
setAddTargetOpen(true);
|
||||||
@@ -449,9 +760,30 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
const requiredErrors = {};
|
||||||
|
components.forEach((comp) => {
|
||||||
|
if (!comp || !comp.localId) return;
|
||||||
|
(Array.isArray(comp.variables) ? comp.variables : []).forEach((variable) => {
|
||||||
|
if (!variable || !variable.name || !variable.required) return;
|
||||||
|
if ((variable.type || "string") === "boolean") return;
|
||||||
|
const value = variable.value;
|
||||||
|
if (value == null || value === "") {
|
||||||
|
if (!requiredErrors[comp.localId]) requiredErrors[comp.localId] = {};
|
||||||
|
requiredErrors[comp.localId][variable.name] = "Required";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (Object.keys(requiredErrors).length) {
|
||||||
|
setComponentVarErrors(requiredErrors);
|
||||||
|
setTab(1);
|
||||||
|
alert("Please fill in all required variable values.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComponentVarErrors({});
|
||||||
|
const payloadComponents = sanitizeComponentsForSave(components);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: jobName,
|
name: jobName,
|
||||||
components,
|
components: payloadComponents,
|
||||||
targets,
|
targets,
|
||||||
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
|
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
|
||||||
duration: { stopAfterEnabled, expiration },
|
duration: { stopAfterEnabled, expiration },
|
||||||
@@ -553,9 +885,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
{components.length === 0 && (
|
{components.length === 0 && (
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>No assemblies added yet.</Typography>
|
<Typography variant="body2" sx={{ color: "#888" }}>No assemblies added yet.</Typography>
|
||||||
)}
|
)}
|
||||||
{components.map((c, idx) => (
|
{components.map((c) => (
|
||||||
<ComponentCard key={`${c.type}-${c.path}-${idx}`} comp={c}
|
<ComponentCard
|
||||||
onRemove={() => setComponents((prev) => prev.filter((_, i) => i !== idx))}
|
key={c.localId || `${c.type}-${c.path}`}
|
||||||
|
comp={c}
|
||||||
|
onRemove={removeComponent}
|
||||||
|
onVariableChange={updateComponentVariable}
|
||||||
|
errors={componentVarErrors[c.localId] || {}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{components.length === 0 && (
|
{components.length === 0 && (
|
||||||
@@ -820,7 +1156,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||||
<Button onClick={() => { const ok = addSelectedComponent(); if (ok) setAddCompOpen(false); }}
|
<Button onClick={async () => { const ok = await addSelectedComponent(); if (ok) setAddCompOpen(false); }}
|
||||||
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
|
sx={{ color: "#58a6ff" }} disabled={!selectedNodeId}
|
||||||
>Add</Button>
|
>Add</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Paper,
|
Paper,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Checkbox
|
Checkbox,
|
||||||
|
TextField
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||||
@@ -81,6 +82,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
||||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
||||||
|
const [variables, setVariables] = useState([]);
|
||||||
|
const [variableValues, setVariableValues] = useState({});
|
||||||
|
const [variableErrors, setVariableErrors] = useState({});
|
||||||
|
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
const loadTree = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +107,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
if (open) {
|
if (open) {
|
||||||
setSelectedPath("");
|
setSelectedPath("");
|
||||||
setError("");
|
setError("");
|
||||||
|
setVariables([]);
|
||||||
|
setVariableValues({});
|
||||||
|
setVariableErrors({});
|
||||||
|
setVariableStatus({ loading: false, error: "" });
|
||||||
loadTree();
|
loadTree();
|
||||||
}
|
}
|
||||||
}, [open, loadTree]);
|
}, [open, loadTree]);
|
||||||
@@ -131,24 +140,181 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
if (node && !node.isFolder) {
|
if (node && !node.isFolder) {
|
||||||
setSelectedPath(node.path);
|
setSelectedPath(node.path);
|
||||||
setError("");
|
setError("");
|
||||||
|
setVariableErrors({});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeVariables = (list) => {
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list
|
||||||
|
.map((raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
|
||||||
|
if (!name) return null;
|
||||||
|
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
|
||||||
|
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
|
||||||
|
const description = typeof raw.description === "string" ? raw.description : "";
|
||||||
|
const required = Boolean(raw.required);
|
||||||
|
const defaultValue = raw.hasOwnProperty("default")
|
||||||
|
? raw.default
|
||||||
|
: raw.hasOwnProperty("defaultValue")
|
||||||
|
? raw.defaultValue
|
||||||
|
: raw.hasOwnProperty("default_value")
|
||||||
|
? raw.default_value
|
||||||
|
: "";
|
||||||
|
return { name, label, type, description, required, default: defaultValue };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveInitialValue = (variable) => {
|
||||||
|
const { type, default: defaultValue } = variable;
|
||||||
|
if (type === "boolean") {
|
||||||
|
if (typeof defaultValue === "boolean") return defaultValue;
|
||||||
|
if (defaultValue == null) return false;
|
||||||
|
const str = String(defaultValue).trim().toLowerCase();
|
||||||
|
if (!str) return false;
|
||||||
|
return ["true", "1", "yes", "on"].includes(str);
|
||||||
|
}
|
||||||
|
if (type === "number") {
|
||||||
|
if (defaultValue == null || defaultValue === "") return "";
|
||||||
|
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
|
||||||
|
return String(defaultValue);
|
||||||
|
}
|
||||||
|
const parsed = Number(defaultValue);
|
||||||
|
return Number.isFinite(parsed) ? String(parsed) : "";
|
||||||
|
}
|
||||||
|
return defaultValue == null ? "" : String(defaultValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPath) {
|
||||||
|
setVariables([]);
|
||||||
|
setVariableValues({});
|
||||||
|
setVariableErrors({});
|
||||||
|
setVariableStatus({ loading: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let canceled = false;
|
||||||
|
const loadAssembly = async () => {
|
||||||
|
setVariableStatus({ loading: true, error: "" });
|
||||||
|
try {
|
||||||
|
const island = mode === "ansible" ? "ansible" : "scripts";
|
||||||
|
const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setVariables([]);
|
||||||
|
setVariableValues({});
|
||||||
|
setVariableErrors({});
|
||||||
|
setVariableStatus({ loading: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let relPath = trimmed;
|
||||||
|
if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) {
|
||||||
|
relPath = relPath.slice("Scripts/".length);
|
||||||
|
} else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) {
|
||||||
|
relPath = relPath.slice("Ansible_Playbooks/".length);
|
||||||
|
}
|
||||||
|
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
|
||||||
|
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const defs = normalizeVariables(data?.assembly?.variables || []);
|
||||||
|
if (!canceled) {
|
||||||
|
setVariables(defs);
|
||||||
|
const initialValues = {};
|
||||||
|
defs.forEach((v) => {
|
||||||
|
initialValues[v.name] = deriveInitialValue(v);
|
||||||
|
});
|
||||||
|
setVariableValues(initialValues);
|
||||||
|
setVariableErrors({});
|
||||||
|
setVariableStatus({ loading: false, error: "" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!canceled) {
|
||||||
|
setVariables([]);
|
||||||
|
setVariableValues({});
|
||||||
|
setVariableErrors({});
|
||||||
|
setVariableStatus({ loading: false, error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAssembly();
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [selectedPath, mode]);
|
||||||
|
|
||||||
|
const handleVariableChange = (variable, rawValue) => {
|
||||||
|
const { name, type } = variable;
|
||||||
|
if (!name) return;
|
||||||
|
setVariableValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
|
||||||
|
}));
|
||||||
|
setVariableErrors((prev) => {
|
||||||
|
if (!prev[name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVariablePayload = () => {
|
||||||
|
const payload = {};
|
||||||
|
variables.forEach((variable) => {
|
||||||
|
if (!variable?.name) return;
|
||||||
|
const { name, type } = variable;
|
||||||
|
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
|
||||||
|
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
|
||||||
|
if (type === "boolean") {
|
||||||
|
payload[name] = Boolean(raw);
|
||||||
|
} else if (type === "number") {
|
||||||
|
if (raw === "" || raw === null || raw === undefined) {
|
||||||
|
payload[name] = "";
|
||||||
|
} else {
|
||||||
|
const num = Number(raw);
|
||||||
|
payload[name] = Number.isFinite(num) ? num : "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload[name] = raw == null ? "" : String(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
const onRun = async () => {
|
const onRun = async () => {
|
||||||
if (!selectedPath) {
|
if (!selectedPath) {
|
||||||
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (variables.length) {
|
||||||
|
const errors = {};
|
||||||
|
variables.forEach((variable) => {
|
||||||
|
if (!variable) return;
|
||||||
|
if (!variable.required) return;
|
||||||
|
if (variable.type === "boolean") return;
|
||||||
|
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
|
||||||
|
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
|
||||||
|
if (raw == null || raw === "") {
|
||||||
|
errors[variable.name] = "Required";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
setVariableErrors(errors);
|
||||||
|
setError("Please fill in all required variable values.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
let resp;
|
let resp;
|
||||||
|
const variableOverrides = buildVariablePayload();
|
||||||
if (mode === 'ansible') {
|
if (mode === 'ansible') {
|
||||||
const playbook_path = selectedPath; // relative to ansible island
|
const playbook_path = selectedPath; // relative to ansible island
|
||||||
resp = await fetch("/api/ansible/quick_run", {
|
resp = await fetch("/api/ansible/quick_run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ playbook_path, hostnames })
|
body: JSON.stringify({ playbook_path, hostnames, variable_values: variableOverrides })
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
||||||
@@ -156,7 +322,12 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
resp = await fetch("/api/scripts/quick_run", {
|
resp = await fetch("/api/scripts/quick_run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
|
body: JSON.stringify({
|
||||||
|
script_path,
|
||||||
|
hostnames,
|
||||||
|
run_mode: runAsCurrentUser ? "current_user" : "system",
|
||||||
|
variable_values: variableOverrides
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -210,6 +381,61 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
||||||
|
{variableStatus.loading ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables…</Typography>
|
||||||
|
) : variableStatus.error ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
|
||||||
|
) : variables.length ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<Box key={variable.name}>
|
||||||
|
{variable.type === "boolean" ? (
|
||||||
|
<FormControlLabel
|
||||||
|
control={(
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={Boolean(variableValues[variable.name])}
|
||||||
|
onChange={(e) => handleVariableChange(variable, e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
{variable.label}
|
||||||
|
{variable.required ? " *" : ""}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label={`${variable.label}${variable.required ? " *" : ""}`}
|
||||||
|
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
||||||
|
value={variableValues[variable.name] ?? ""}
|
||||||
|
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
|
||||||
|
"& .MuiInputBase-input": { color: "#e6edf3" }
|
||||||
|
}}
|
||||||
|
error={Boolean(variableErrors[variable.name])}
|
||||||
|
helperText={variableErrors[variable.name] || variable.description || ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{variable.type === "boolean" && variable.description ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
||||||
|
{variable.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{error && (
|
{error && (
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Callable
|
from typing import Any, Dict, List, Optional, Tuple, Callable
|
||||||
@@ -25,6 +26,189 @@ def _now_ts() -> int:
|
|||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _env_string(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "True" if value else "False"
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cleaned = re.sub(r"\s+", "", stripped)
|
||||||
|
except Exception:
|
||||||
|
cleaned = stripped
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
|
||||||
|
encoding = (encoding_hint or "").strip().lower()
|
||||||
|
if isinstance(value, str):
|
||||||
|
if encoding in ("base64", "b64", "base-64"):
|
||||||
|
decoded = _decode_base64_text(value)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded.replace("\r\n", "\n")
|
||||||
|
decoded = _decode_base64_text(value)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded.replace("\r\n", "\n")
|
||||||
|
return value.replace("\r\n", "\n")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_script_content(script_text: Any) -> str:
|
||||||
|
if not isinstance(script_text, str):
|
||||||
|
if script_text is None:
|
||||||
|
script_text = ""
|
||||||
|
else:
|
||||||
|
script_text = str(script_text)
|
||||||
|
normalized = script_text.replace("\r\n", "\n")
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
encoded = base64.b64encode(normalized.encode("utf-8"))
|
||||||
|
return encoded.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_env_key(name: Any) -> str:
|
||||||
|
try:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
expanded: Dict[str, str] = dict(env_map or {})
|
||||||
|
if not isinstance(variables, list):
|
||||||
|
return expanded
|
||||||
|
for var in variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical or canonical not in expanded:
|
||||||
|
continue
|
||||||
|
value = expanded[canonical]
|
||||||
|
alias = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||||
|
if alias and alias not in expanded:
|
||||||
|
expanded[alias] = value
|
||||||
|
if alias != name and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in expanded:
|
||||||
|
expanded[name] = value
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _powershell_literal(value: Any, var_type: str) -> str:
|
||||||
|
typ = str(var_type or "string").lower()
|
||||||
|
if typ == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
truthy = value
|
||||||
|
elif value is None:
|
||||||
|
truthy = False
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
truthy = value != 0
|
||||||
|
else:
|
||||||
|
s = str(value).strip().lower()
|
||||||
|
if s in {"true", "1", "yes", "y", "on"}:
|
||||||
|
truthy = True
|
||||||
|
elif s in {"false", "0", "no", "n", "off", ""}:
|
||||||
|
truthy = False
|
||||||
|
else:
|
||||||
|
truthy = bool(s)
|
||||||
|
return "$true" if truthy else "$false"
|
||||||
|
if typ == "number":
|
||||||
|
if value is None or value == "":
|
||||||
|
return "0"
|
||||||
|
return str(value)
|
||||||
|
s = "" if value is None else str(value)
|
||||||
|
return "'" + s.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_variable_default(var: Dict[str, Any]) -> Any:
|
||||||
|
for key in ("value", "default", "defaultValue", "default_value"):
|
||||||
|
if key in var:
|
||||||
|
val = var.get(key)
|
||||||
|
return "" if val is None else val
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_variable_context(doc_variables: List[Dict[str, Any]], overrides: Dict[str, Any]):
|
||||||
|
env_map: Dict[str, str] = {}
|
||||||
|
variables: List[Dict[str, Any]] = []
|
||||||
|
literal_lookup: Dict[str, str] = {}
|
||||||
|
doc_names: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
overrides = overrides or {}
|
||||||
|
|
||||||
|
if not isinstance(doc_variables, list):
|
||||||
|
doc_variables = []
|
||||||
|
|
||||||
|
for var in doc_variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
doc_names[name] = True
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
var_type = str(var.get("type") or "string").lower()
|
||||||
|
default_val = _extract_variable_default(var)
|
||||||
|
final_val = overrides[name] if name in overrides else default_val
|
||||||
|
if canonical:
|
||||||
|
env_map[canonical] = _env_string(final_val)
|
||||||
|
literal_lookup[canonical] = _powershell_literal(final_val, var_type)
|
||||||
|
if name in overrides:
|
||||||
|
new_var = dict(var)
|
||||||
|
new_var["value"] = overrides[name]
|
||||||
|
variables.append(new_var)
|
||||||
|
else:
|
||||||
|
variables.append(var)
|
||||||
|
|
||||||
|
for name, val in overrides.items():
|
||||||
|
if name in doc_names:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if canonical:
|
||||||
|
env_map[canonical] = _env_string(val)
|
||||||
|
literal_lookup[canonical] = _powershell_literal(val, "string")
|
||||||
|
variables.append({"name": name, "value": val, "type": "string"})
|
||||||
|
|
||||||
|
env_map = _expand_env_aliases(env_map, variables)
|
||||||
|
return env_map, variables, literal_lookup
|
||||||
|
|
||||||
|
|
||||||
|
_ENV_VAR_PATTERN = re.compile(r"(?i)\$env:(\{)?([A-Za-z0-9_\-]+)(?(1)\})")
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> str:
|
||||||
|
if not content or not literal_lookup:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _replace(match: Any) -> str:
|
||||||
|
name = match.group(2)
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical:
|
||||||
|
return match.group(0)
|
||||||
|
literal = literal_lookup.get(canonical)
|
||||||
|
if literal is None:
|
||||||
|
return match.group(0)
|
||||||
|
return literal
|
||||||
|
|
||||||
|
return _ENV_VAR_PATTERN.sub(_replace, content)
|
||||||
|
|
||||||
|
|
||||||
def _parse_ts(val: Any) -> Optional[int]:
|
def _parse_ts(val: Any) -> Optional[int]:
|
||||||
"""Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds."""
|
"""Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds."""
|
||||||
if val is None:
|
if val is None:
|
||||||
@@ -202,6 +386,7 @@ class JobScheduler:
|
|||||||
if typ in ("powershell", "batch", "bash", "ansible"):
|
if typ in ("powershell", "batch", "bash", "ansible"):
|
||||||
doc["type"] = typ
|
doc["type"] = typ
|
||||||
script_val = data.get("script")
|
script_val = data.get("script")
|
||||||
|
content_val = data.get("content")
|
||||||
script_lines = data.get("script_lines")
|
script_lines = data.get("script_lines")
|
||||||
if isinstance(script_lines, list):
|
if isinstance(script_lines, list):
|
||||||
try:
|
try:
|
||||||
@@ -211,11 +396,24 @@ class JobScheduler:
|
|||||||
elif isinstance(script_val, str):
|
elif isinstance(script_val, str):
|
||||||
doc["script"] = script_val
|
doc["script"] = script_val
|
||||||
else:
|
else:
|
||||||
content_val = data.get("content")
|
|
||||||
if isinstance(content_val, str):
|
if isinstance(content_val, str):
|
||||||
doc["script"] = content_val
|
doc["script"] = content_val
|
||||||
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
|
encoding_hint = str(data.get("script_encoding") or data.get("scriptEncoding") or "").strip().lower()
|
||||||
doc["script"] = normalized_script
|
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
|
||||||
|
if encoding_hint in ("base64", "b64", "base-64"):
|
||||||
|
doc["script_encoding"] = "base64"
|
||||||
|
else:
|
||||||
|
probe_source = ""
|
||||||
|
if isinstance(script_val, str) and script_val:
|
||||||
|
probe_source = script_val
|
||||||
|
elif isinstance(content_val, str) and content_val:
|
||||||
|
probe_source = content_val
|
||||||
|
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
|
||||||
|
if decoded_probe is not None:
|
||||||
|
doc["script_encoding"] = "base64"
|
||||||
|
doc["script"] = decoded_probe.replace("\r\n", "\n")
|
||||||
|
else:
|
||||||
|
doc["script_encoding"] = "plain"
|
||||||
try:
|
try:
|
||||||
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
|
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
|
||||||
if timeout_raw is None:
|
if timeout_raw is None:
|
||||||
@@ -287,6 +485,7 @@ class JobScheduler:
|
|||||||
return
|
return
|
||||||
doc = self._load_assembly_document(abs_path, "ansible")
|
doc = self._load_assembly_document(abs_path, "ansible")
|
||||||
content = doc.get("script") or ""
|
content = doc.get("script") or ""
|
||||||
|
encoded_content = _encode_script_content(content)
|
||||||
variables = doc.get("variables") or []
|
variables = doc.get("variables") or []
|
||||||
files = doc.get("files") or []
|
files = doc.get("files") or []
|
||||||
|
|
||||||
@@ -321,7 +520,8 @@ class JobScheduler:
|
|||||||
"run_id": uuid.uuid4().hex,
|
"run_id": uuid.uuid4().hex,
|
||||||
"target_hostname": str(hostname),
|
"target_hostname": str(hostname),
|
||||||
"playbook_name": os.path.basename(abs_path),
|
"playbook_name": os.path.basename(abs_path),
|
||||||
"playbook_content": content,
|
"playbook_content": encoded_content,
|
||||||
|
"playbook_encoding": "base64",
|
||||||
"activity_job_id": act_id,
|
"activity_job_id": act_id,
|
||||||
"scheduled_job_id": int(scheduled_job_id),
|
"scheduled_job_id": int(scheduled_job_id),
|
||||||
"scheduled_run_id": int(scheduled_run_id),
|
"scheduled_run_id": int(scheduled_run_id),
|
||||||
@@ -336,14 +536,21 @@ class JobScheduler:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _dispatch_script(self, hostname: str, rel_path: str, run_mode: str) -> None:
|
def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> None:
|
||||||
"""Emit a quick_job_run event to agents for the given script/host.
|
"""Emit a quick_job_run event to agents for the given script/host.
|
||||||
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
scripts_root = self._scripts_root()
|
scripts_root = self._scripts_root()
|
||||||
import os
|
import os
|
||||||
path_norm = (rel_path or "").replace("\\", "/")
|
rel_path_raw = ""
|
||||||
|
if isinstance(component, dict):
|
||||||
|
rel_path_raw = str(component.get("path") or component.get("script_path") or "")
|
||||||
|
else:
|
||||||
|
rel_path_raw = str(component or "")
|
||||||
|
path_norm = (rel_path_raw or "").replace("\\", "/").strip()
|
||||||
|
if path_norm and not path_norm.startswith("Scripts/"):
|
||||||
|
path_norm = f"Scripts/{path_norm}"
|
||||||
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
||||||
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
|
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
|
||||||
return
|
return
|
||||||
@@ -353,22 +560,28 @@ class JobScheduler:
|
|||||||
if stype != "powershell":
|
if stype != "powershell":
|
||||||
return
|
return
|
||||||
content = doc.get("script") or ""
|
content = doc.get("script") or ""
|
||||||
env_map: Dict[str, str] = {}
|
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||||
for var in doc.get("variables") or []:
|
|
||||||
if not isinstance(var, dict):
|
overrides: Dict[str, Any] = {}
|
||||||
continue
|
if isinstance(component, dict):
|
||||||
name = str(var.get("name") or "").strip()
|
if isinstance(component.get("variable_values"), dict):
|
||||||
if not name:
|
for key, val in component.get("variable_values").items():
|
||||||
continue
|
name = str(key or "").strip()
|
||||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
if name:
|
||||||
default_val = var.get("default")
|
overrides[name] = val
|
||||||
if isinstance(default_val, bool):
|
if isinstance(component.get("variables"), list):
|
||||||
env_val = "True" if default_val else "False"
|
for var in component.get("variables"):
|
||||||
elif default_val is None:
|
if not isinstance(var, dict):
|
||||||
env_val = ""
|
continue
|
||||||
else:
|
name = str(var.get("name") or "").strip()
|
||||||
env_val = str(default_val)
|
if not name:
|
||||||
env_map[env_key] = env_val
|
continue
|
||||||
|
if "value" in var:
|
||||||
|
overrides[name] = var.get("value")
|
||||||
|
|
||||||
|
env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides)
|
||||||
|
content = _rewrite_powershell_script(content, literal_lookup)
|
||||||
|
encoded_content = _encode_script_content(content)
|
||||||
timeout_seconds = 0
|
timeout_seconds = 0
|
||||||
try:
|
try:
|
||||||
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
||||||
@@ -409,9 +622,10 @@ class JobScheduler:
|
|||||||
"script_type": stype,
|
"script_type": stype,
|
||||||
"script_name": os.path.basename(abs_path),
|
"script_name": os.path.basename(abs_path),
|
||||||
"script_path": path_norm,
|
"script_path": path_norm,
|
||||||
"script_content": content,
|
"script_content": encoded_content,
|
||||||
|
"script_encoding": "base64",
|
||||||
"environment": env_map,
|
"environment": env_map,
|
||||||
"variables": doc.get("variables") or [],
|
"variables": variables,
|
||||||
"timeout_seconds": timeout_seconds,
|
"timeout_seconds": timeout_seconds,
|
||||||
"files": doc.get("files") or [],
|
"files": doc.get("files") or [],
|
||||||
"run_mode": (run_mode or "system").strip().lower(),
|
"run_mode": (run_mode or "system").strip().lower(),
|
||||||
@@ -653,7 +867,7 @@ class JobScheduler:
|
|||||||
comps = json.loads(components_json or "[]")
|
comps = json.loads(components_json or "[]")
|
||||||
except Exception:
|
except Exception:
|
||||||
comps = []
|
comps = []
|
||||||
script_paths = []
|
script_components = []
|
||||||
ansible_paths = []
|
ansible_paths = []
|
||||||
for c in comps:
|
for c in comps:
|
||||||
try:
|
try:
|
||||||
@@ -661,7 +875,9 @@ class JobScheduler:
|
|||||||
if ctype == "script":
|
if ctype == "script":
|
||||||
p = (c.get("path") or c.get("script_path") or "").strip()
|
p = (c.get("path") or c.get("script_path") or "").strip()
|
||||||
if p:
|
if p:
|
||||||
script_paths.append(p)
|
comp_copy = dict(c)
|
||||||
|
comp_copy["path"] = p
|
||||||
|
script_components.append(comp_copy)
|
||||||
elif ctype == "ansible":
|
elif ctype == "ansible":
|
||||||
p = (c.get("path") or "").strip()
|
p = (c.get("path") or "").strip()
|
||||||
if p:
|
if p:
|
||||||
@@ -755,9 +971,9 @@ class JobScheduler:
|
|||||||
run_row_id = c2.lastrowid or 0
|
run_row_id = c2.lastrowid or 0
|
||||||
conn2.commit()
|
conn2.commit()
|
||||||
# Dispatch all script components for this job to the target host
|
# Dispatch all script components for this job to the target host
|
||||||
for sp in script_paths:
|
for comp in script_components:
|
||||||
try:
|
try:
|
||||||
self._dispatch_script(host, sp, run_mode)
|
self._dispatch_script(host, comp, run_mode)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
# Dispatch ansible playbooks for this job to the target host
|
# Dispatch ansible playbooks for this job to the target host
|
||||||
|
|||||||
@@ -689,6 +689,64 @@ def _empty_assembly_document(default_type: str = "powershell") -> Dict[str, Any]
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_text(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
cleaned = re.sub(r"\s+", "", stripped)
|
||||||
|
except Exception:
|
||||||
|
cleaned = stripped
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return decoded.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
|
||||||
|
encoding = (encoding_hint or "").strip().lower()
|
||||||
|
if isinstance(value, str):
|
||||||
|
if encoding in ("base64", "b64", "base-64"):
|
||||||
|
decoded = _decode_base64_text(value)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded.replace("\r\n", "\n")
|
||||||
|
decoded = _decode_base64_text(value)
|
||||||
|
if decoded is not None:
|
||||||
|
return decoded.replace("\r\n", "\n")
|
||||||
|
return value.replace("\r\n", "\n")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_script_content(script_text: Any) -> str:
|
||||||
|
if not isinstance(script_text, str):
|
||||||
|
if script_text is None:
|
||||||
|
script_text = ""
|
||||||
|
else:
|
||||||
|
script_text = str(script_text)
|
||||||
|
normalized = script_text.replace("\r\n", "\n")
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
encoded = base64.b64encode(normalized.encode("utf-8"))
|
||||||
|
return encoded.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_assembly_storage(doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
stored: Dict[str, Any] = {}
|
||||||
|
for key, value in (doc or {}).items():
|
||||||
|
if key == "script":
|
||||||
|
stored[key] = _encode_script_content(value)
|
||||||
|
else:
|
||||||
|
stored[key] = value
|
||||||
|
stored["script_encoding"] = "base64"
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> Dict[str, Any]:
|
def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> Dict[str, Any]:
|
||||||
doc = _empty_assembly_document(default_type)
|
doc = _empty_assembly_document(default_type)
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
@@ -703,6 +761,7 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) ->
|
|||||||
if typ in ("powershell", "batch", "bash", "ansible"):
|
if typ in ("powershell", "batch", "bash", "ansible"):
|
||||||
doc["type"] = typ
|
doc["type"] = typ
|
||||||
script_val = obj.get("script")
|
script_val = obj.get("script")
|
||||||
|
content_val = obj.get("content")
|
||||||
script_lines = obj.get("script_lines")
|
script_lines = obj.get("script_lines")
|
||||||
if isinstance(script_lines, list):
|
if isinstance(script_lines, list):
|
||||||
try:
|
try:
|
||||||
@@ -712,11 +771,24 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) ->
|
|||||||
elif isinstance(script_val, str):
|
elif isinstance(script_val, str):
|
||||||
doc["script"] = script_val
|
doc["script"] = script_val
|
||||||
else:
|
else:
|
||||||
content_val = obj.get("content")
|
|
||||||
if isinstance(content_val, str):
|
if isinstance(content_val, str):
|
||||||
doc["script"] = content_val
|
doc["script"] = content_val
|
||||||
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
|
encoding_hint = str(obj.get("script_encoding") or obj.get("scriptEncoding") or "").strip().lower()
|
||||||
doc["script"] = normalized_script
|
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
|
||||||
|
if encoding_hint in ("base64", "b64", "base-64"):
|
||||||
|
doc["script_encoding"] = "base64"
|
||||||
|
else:
|
||||||
|
probe_source = ""
|
||||||
|
if isinstance(script_val, str) and script_val:
|
||||||
|
probe_source = script_val
|
||||||
|
elif isinstance(content_val, str) and content_val:
|
||||||
|
probe_source = content_val
|
||||||
|
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
|
||||||
|
if decoded_probe is not None:
|
||||||
|
doc["script_encoding"] = "base64"
|
||||||
|
doc["script"] = decoded_probe.replace("\r\n", "\n")
|
||||||
|
else:
|
||||||
|
doc["script_encoding"] = "plain"
|
||||||
timeout_val = obj.get("timeout_seconds", obj.get("timeout"))
|
timeout_val = obj.get("timeout_seconds", obj.get("timeout"))
|
||||||
if timeout_val is not None:
|
if timeout_val is not None:
|
||||||
try:
|
try:
|
||||||
@@ -853,7 +925,7 @@ def assembly_create():
|
|||||||
base_name,
|
base_name,
|
||||||
)
|
)
|
||||||
with open(abs_path, "w", encoding="utf-8") as fh:
|
with open(abs_path, "w", encoding="utf-8") as fh:
|
||||||
json.dump(normalized, fh, indent=2)
|
json.dump(_prepare_assembly_storage(normalized), fh, indent=2)
|
||||||
rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/")
|
rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/")
|
||||||
return jsonify({"status": "ok", "rel_path": rel_new})
|
return jsonify({"status": "ok", "rel_path": rel_new})
|
||||||
else:
|
else:
|
||||||
@@ -902,7 +974,7 @@ def assembly_edit():
|
|||||||
base_name,
|
base_name,
|
||||||
)
|
)
|
||||||
with open(target_abs, "w", encoding="utf-8") as fh:
|
with open(target_abs, "w", encoding="utf-8") as fh:
|
||||||
json.dump(normalized, fh, indent=2)
|
json.dump(_prepare_assembly_storage(normalized), fh, indent=2)
|
||||||
if target_abs != abs_path:
|
if target_abs != abs_path:
|
||||||
try:
|
try:
|
||||||
os.remove(abs_path)
|
os.remove(abs_path)
|
||||||
@@ -2816,6 +2888,144 @@ def _safe_filename(rel_path: str) -> str:
|
|||||||
return rel_path or ""
|
return rel_path or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _env_string(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "True" if value else "False"
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_env_key(name: Any) -> str:
|
||||||
|
try:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
expanded: Dict[str, str] = dict(env_map or {})
|
||||||
|
if not isinstance(variables, list):
|
||||||
|
return expanded
|
||||||
|
for var in variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical or canonical not in expanded:
|
||||||
|
continue
|
||||||
|
value = expanded[canonical]
|
||||||
|
alias = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||||
|
if alias and alias not in expanded:
|
||||||
|
expanded[alias] = value
|
||||||
|
if alias != name and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in expanded:
|
||||||
|
expanded[name] = value
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _powershell_literal(value: Any, var_type: str) -> str:
|
||||||
|
"""Convert a variable value to a PowerShell literal for substitution."""
|
||||||
|
typ = str(var_type or "string").lower()
|
||||||
|
if typ == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
truthy = value
|
||||||
|
elif value is None:
|
||||||
|
truthy = False
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
truthy = value != 0
|
||||||
|
else:
|
||||||
|
s = str(value).strip().lower()
|
||||||
|
if s in {"true", "1", "yes", "y", "on"}:
|
||||||
|
truthy = True
|
||||||
|
elif s in {"false", "0", "no", "n", "off", ""}:
|
||||||
|
truthy = False
|
||||||
|
else:
|
||||||
|
truthy = bool(s)
|
||||||
|
return "$true" if truthy else "$false"
|
||||||
|
if typ == "number":
|
||||||
|
if value is None or value == "":
|
||||||
|
return "0"
|
||||||
|
return str(value)
|
||||||
|
# Treat credentials and any other type as strings
|
||||||
|
s = "" if value is None else str(value)
|
||||||
|
return "'" + s.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_variable_default(var: Dict[str, Any]) -> Any:
|
||||||
|
for key in ("value", "default", "defaultValue", "default_value"):
|
||||||
|
if key in var:
|
||||||
|
val = var.get(key)
|
||||||
|
return "" if val is None else val
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_variable_context(doc_variables: List[Dict[str, Any]], overrides: Dict[str, Any]):
|
||||||
|
env_map: Dict[str, str] = {}
|
||||||
|
variables: List[Dict[str, Any]] = []
|
||||||
|
literal_lookup: Dict[str, str] = {}
|
||||||
|
doc_names: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
overrides = overrides or {}
|
||||||
|
|
||||||
|
if not isinstance(doc_variables, list):
|
||||||
|
doc_variables = []
|
||||||
|
|
||||||
|
for var in doc_variables:
|
||||||
|
if not isinstance(var, dict):
|
||||||
|
continue
|
||||||
|
name = str(var.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
doc_names[name] = True
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
var_type = str(var.get("type") or "string").lower()
|
||||||
|
default_val = _extract_variable_default(var)
|
||||||
|
final_val = overrides[name] if name in overrides else default_val
|
||||||
|
if canonical:
|
||||||
|
env_map[canonical] = _env_string(final_val)
|
||||||
|
literal_lookup[canonical] = _powershell_literal(final_val, var_type)
|
||||||
|
if name in overrides:
|
||||||
|
new_var = dict(var)
|
||||||
|
new_var["value"] = overrides[name]
|
||||||
|
variables.append(new_var)
|
||||||
|
else:
|
||||||
|
variables.append(var)
|
||||||
|
|
||||||
|
for name, val in overrides.items():
|
||||||
|
if name in doc_names:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if canonical:
|
||||||
|
env_map[canonical] = _env_string(val)
|
||||||
|
literal_lookup[canonical] = _powershell_literal(val, "string")
|
||||||
|
variables.append({"name": name, "value": val, "type": "string"})
|
||||||
|
|
||||||
|
env_map = _expand_env_aliases(env_map, variables)
|
||||||
|
return env_map, variables, literal_lookup
|
||||||
|
|
||||||
|
|
||||||
|
_ENV_VAR_PATTERN = re.compile(r"(?i)\$env:(\{)?([A-Za-z0-9_\-]+)(?(1)\})")
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> str:
|
||||||
|
if not content or not literal_lookup:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _replace(match: Any) -> str:
|
||||||
|
name = match.group(2)
|
||||||
|
canonical = _canonical_env_key(name)
|
||||||
|
if not canonical:
|
||||||
|
return match.group(0)
|
||||||
|
literal = literal_lookup.get(canonical)
|
||||||
|
if literal is None:
|
||||||
|
return match.group(0)
|
||||||
|
return literal
|
||||||
|
|
||||||
|
return _ENV_VAR_PATTERN.sub(_replace, content)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/scripts/quick_run", methods=["POST"])
|
@app.route("/api/scripts/quick_run", methods=["POST"])
|
||||||
def scripts_quick_run():
|
def scripts_quick_run():
|
||||||
"""Queue a Quick Job to agents via WebSocket and record Running status.
|
"""Queue a Quick Job to agents via WebSocket and record Running status.
|
||||||
@@ -2843,23 +3053,20 @@ def scripts_quick_run():
|
|||||||
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
|
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
|
||||||
|
|
||||||
content = doc.get("script") or ""
|
content = doc.get("script") or ""
|
||||||
variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||||
env_map: Dict[str, str] = {}
|
|
||||||
for var in variables:
|
overrides_raw = data.get("variable_values")
|
||||||
if not isinstance(var, dict):
|
overrides: Dict[str, Any] = {}
|
||||||
continue
|
if isinstance(overrides_raw, dict):
|
||||||
name = str(var.get("name") or "").strip()
|
for key, val in overrides_raw.items():
|
||||||
if not name:
|
name = str(key or "").strip()
|
||||||
continue
|
if not name:
|
||||||
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
|
continue
|
||||||
default_val = var.get("default")
|
overrides[name] = val
|
||||||
if isinstance(default_val, bool):
|
|
||||||
env_val = "True" if default_val else "False"
|
env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides)
|
||||||
elif default_val is None:
|
content = _rewrite_powershell_script(content, literal_lookup)
|
||||||
env_val = ""
|
encoded_content = _encode_script_content(content)
|
||||||
else:
|
|
||||||
env_val = str(default_val)
|
|
||||||
env_map[env_key] = env_val
|
|
||||||
timeout_seconds = 0
|
timeout_seconds = 0
|
||||||
try:
|
try:
|
||||||
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
||||||
@@ -2901,7 +3108,8 @@ def scripts_quick_run():
|
|||||||
"script_type": script_type,
|
"script_type": script_type,
|
||||||
"script_name": _safe_filename(rel_path),
|
"script_name": _safe_filename(rel_path),
|
||||||
"script_path": rel_path.replace(os.sep, "/"),
|
"script_path": rel_path.replace(os.sep, "/"),
|
||||||
"script_content": content,
|
"script_content": encoded_content,
|
||||||
|
"script_encoding": "base64",
|
||||||
"environment": env_map,
|
"environment": env_map,
|
||||||
"variables": variables,
|
"variables": variables,
|
||||||
"timeout_seconds": timeout_seconds,
|
"timeout_seconds": timeout_seconds,
|
||||||
@@ -2937,6 +3145,7 @@ def ansible_quick_run():
|
|||||||
return jsonify({"error": "Playbook not found"}), 404
|
return jsonify({"error": "Playbook not found"}), 404
|
||||||
doc = _load_assembly_document(abs_path, 'ansible')
|
doc = _load_assembly_document(abs_path, 'ansible')
|
||||||
content = doc.get('script') or ''
|
content = doc.get('script') or ''
|
||||||
|
encoded_content = _encode_script_content(content)
|
||||||
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
|
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
|
||||||
files = doc.get('files') if isinstance(doc.get('files'), list) else []
|
files = doc.get('files') if isinstance(doc.get('files'), list) else []
|
||||||
|
|
||||||
@@ -2979,7 +3188,8 @@ def ansible_quick_run():
|
|||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"target_hostname": str(host),
|
"target_hostname": str(host),
|
||||||
"playbook_name": os.path.basename(abs_path),
|
"playbook_name": os.path.basename(abs_path),
|
||||||
"playbook_content": content,
|
"playbook_content": encoded_content,
|
||||||
|
"playbook_encoding": "base64",
|
||||||
"connection": "winrm",
|
"connection": "winrm",
|
||||||
"variables": variables,
|
"variables": variables,
|
||||||
"files": files,
|
"files": files,
|
||||||
|
|||||||
Reference in New Issue
Block a user