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:
2025-10-03 21:35:38 -06:00
committed by GitHub
9 changed files with 1432 additions and 132 deletions

View File

@@ -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'))

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}" })

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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

View File

@@ -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,