Merge pull request #91 from bunny-lab-io:codex/evaluate-alternative-solutions-for-ansible-playbooks

Provision reusable Ansible execution environment and track EE version
This commit is contained in:
2025-10-15 03:40:07 -06:00
committed by GitHub
10 changed files with 771 additions and 106 deletions

View File

@@ -256,6 +256,19 @@ $nodeExe = Join-Path $depsRoot 'NodeJS\node.exe'
$sevenZipExe = Join-Path $depsRoot "7zip\7z.exe" $sevenZipExe = Join-Path $depsRoot "7zip\7z.exe"
$npmCmd = Join-Path (Split-Path $nodeExe) 'npm.cmd' $npmCmd = Join-Path (Split-Path $nodeExe) 'npm.cmd'
$npxCmd = Join-Path (Split-Path $nodeExe) 'npx.cmd' $npxCmd = Join-Path (Split-Path $nodeExe) 'npx.cmd'
$ansibleEeRequirementsPath = Join-Path $scriptDir 'Data\Agent\ansible-ee-requirements.txt'
$ansibleEeVersionFile = Join-Path $scriptDir 'Data\Agent\ansible-ee-version.txt'
$script:AnsibleExecutionEnvironmentVersion = '1.0.0'
if (Test-Path $ansibleEeVersionFile -PathType Leaf) {
try {
$rawVersion = (Get-Content -Path $ansibleEeVersionFile -Raw -ErrorAction Stop)
if ($rawVersion) {
$script:AnsibleExecutionEnvironmentVersion = ($rawVersion.Split("`n")[0]).Trim()
}
} catch {
# Leave default version value
}
}
$node7zUrl = "https://nodejs.org/dist/v23.11.0/node-v23.11.0-win-x64.7z" $node7zUrl = "https://nodejs.org/dist/v23.11.0/node-v23.11.0-win-x64.7z"
$nodeInstallDir = Join-Path $depsRoot "NodeJS" $nodeInstallDir = Join-Path $depsRoot "NodeJS"
$node7zPath = Join-Path $depsRoot "node-v23.11.0-win-x64.7z" $node7zPath = Join-Path $depsRoot "node-v23.11.0-win-x64.7z"
@@ -449,6 +462,211 @@ function Install_Agent_Dependencies {
} }
} }
function Ensure-AnsibleExecutionEnvironment {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectRoot,
[string]$PythonBootstrapExe,
[string]$RequirementsPath,
[string]$ExpectedVersion = '1.0.0',
[string]$LogName = 'Install.log'
)
$pythonBootstrap = $PythonBootstrapExe
$bundleCandidate = Join-Path $ProjectRoot 'Dependencies\Python\python.exe'
if ([string]::IsNullOrWhiteSpace($pythonBootstrap)) {
$pythonBootstrap = $bundleCandidate
}
if (-not (Test-Path $pythonBootstrap -PathType Leaf)) {
if ((-not [string]::IsNullOrWhiteSpace($PythonBootstrapExe)) -and ($PythonBootstrapExe -ne $pythonBootstrap)) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Provided Python bootstrap path '$PythonBootstrapExe' was not found."
}
if (Test-Path $bundleCandidate -PathType Leaf) {
$pythonBootstrap = $bundleCandidate
} else {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Unable to locate bundled Python bootstrap executable at $bundleCandidate."
throw "Bundled Python executable not found for Ansible execution environment provisioning."
}
}
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Using Python bootstrap at $pythonBootstrap"
$eeRoot = Join-Path $ProjectRoot 'Agent\Ansible_EE'
$metadataPath = Join-Path $eeRoot 'metadata.json'
$versionTxtPath = Join-Path $eeRoot 'version.txt'
$requirementsHash = ''
if ($RequirementsPath -and (Test-Path $RequirementsPath -PathType Leaf)) {
try {
$requirementsHash = (Get-FileHash -Path $RequirementsPath -Algorithm SHA256).Hash
} catch {
$requirementsHash = ''
}
}
$currentVersion = ''
$currentHash = ''
if (Test-Path $metadataPath -PathType Leaf) {
try {
$metaRaw = Get-Content -Path $metadataPath -Raw -ErrorAction Stop
if ($metaRaw) {
$meta = $metaRaw | ConvertFrom-Json -ErrorAction Stop
if ($meta.version) {
$currentVersion = ($meta.version).ToString().Trim()
}
if ($meta.requirements_hash) {
$currentHash = ($meta.requirements_hash).ToString().Trim()
} elseif ($meta.requirements_sha256) {
$currentHash = ($meta.requirements_sha256).ToString().Trim()
}
}
} catch {
$currentVersion = ''
$currentHash = ''
}
}
$pythonCandidates = @(
(Join-Path $eeRoot 'Scripts\python.exe')
(Join-Path $eeRoot 'Scripts\python3.exe')
(Join-Path $eeRoot 'bin\python3')
(Join-Path $eeRoot 'bin\python')
)
$existingPython = $pythonCandidates | Where-Object { Test-Path $_ -PathType Leaf } | Select-Object -First 1
$expectedVersionNorm = $ExpectedVersion
if ([string]::IsNullOrWhiteSpace($expectedVersionNorm)) {
$expectedVersionNorm = '1.0.0'
}
$expectedVersionNorm = $expectedVersionNorm.Trim()
$isUpToDate = $false
if ($existingPython -and $currentVersion -and ($currentVersion -eq $expectedVersionNorm)) {
if (-not $requirementsHash -or ($currentHash -and $currentHash -eq $requirementsHash)) {
$isUpToDate = $true
}
}
if ($isUpToDate) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Existing execution environment is up-to-date (version $currentVersion)."
return
}
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Provisioning execution environment version $expectedVersionNorm."
if (Test-Path $eeRoot) {
try { Remove-Item -Path $eeRoot -Recurse -Force -ErrorAction Stop } catch {}
}
New-Item -ItemType Directory -Force -Path $eeRoot | Out-Null
& $pythonBootstrap -m venv $eeRoot | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] python -m venv failed with exit code $LASTEXITCODE"
throw "Failed to create Ansible execution environment virtual environment."
}
$pythonExe = $pythonCandidates | Where-Object { Test-Path $_ -PathType Leaf } | Select-Object -First 1
if (-not $pythonExe) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Unable to locate python executable inside execution environment."
throw "Ansible execution environment python executable missing after provisioning."
}
& $pythonExe -m pip install --upgrade pip setuptools wheel --disable-pip-version-check | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] pip bootstrap failed with exit code $LASTEXITCODE"
throw "Failed to bootstrap pip inside the Ansible execution environment."
}
if ($RequirementsPath -and (Test-Path $RequirementsPath -PathType Leaf)) {
& $pythonExe -m pip install --disable-pip-version-check -r $RequirementsPath | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] pip install -r requirements failed with exit code $LASTEXITCODE"
throw "Failed to install Ansible execution environment requirements."
}
} else {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Requirements file not found; skipping dependency installation."
}
$metadata = [ordered]@{
version = $expectedVersionNorm
created_utc = (Get-Date).ToUniversalTime().ToString('o')
python = $pythonExe
bootstrap_python = $pythonBootstrap
}
if ($requirementsHash) {
$metadata['requirements_hash'] = $requirementsHash
}
$supportDir = Join-Path $eeRoot 'support'
try {
New-Item -ItemType Directory -Force -Path $supportDir | Out-Null
} catch {}
$fcntlStubPath = Join-Path $supportDir 'fcntl.py'
$fcntlStub = @'
"""Compat shim for POSIX-only fcntl module.
Generated by Borealis to allow Ansible tooling to run on Windows hosts
where the standard library fcntl module is unavailable. The stub provides
symbol constants and no-op function implementations so imports succeed.
"""
LOCK_SH = 1
LOCK_EX = 2
LOCK_UN = 8
LOCK_NB = 4
F_DUPFD = 0
F_GETFD = 1
F_SETFD = 2
F_GETFL = 3
F_SETFL = 4
FD_CLOEXEC = 1
def ioctl(*_args, **_kwargs):
return 0
def fcntl(*_args, **_kwargs):
return 0
def flock(*_args, **_kwargs):
return 0
def lockf(*_args, **_kwargs):
return 0
'@
try {
if (-not (Test-Path (Join-Path $supportDir '__init__.py') -PathType Leaf)) {
Set-Content -Path (Join-Path $supportDir '__init__.py') -Value '' -Encoding UTF8NoBOM
}
Set-Content -Path $fcntlStubPath -Value $fcntlStub -Encoding UTF8NoBOM
} catch {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Failed to seed Windows fcntl compatibility shim: $($_.Exception.Message)"
}
try {
$metadata | ConvertTo-Json -Depth 5 | Set-Content -Path $metadataPath -Encoding UTF8NoBOM
} catch {
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Failed to persist metadata.json: $($_.Exception.Message)"
throw "Unable to persist Ansible execution environment metadata."
}
try {
Set-Content -Path $versionTxtPath -Value $expectedVersionNorm -Encoding UTF8NoBOM
} catch {}
Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Execution environment ready at $eeRoot"
}
function Ensure-AgentTasks { function Ensure-AgentTasks {
param([string]$ScriptRoot) param([string]$ScriptRoot)
$pyw = Join-Path $ScriptRoot 'Agent\Scripts\pythonw.exe' $pyw = Join-Path $ScriptRoot 'Agent\Scripts\pythonw.exe'
@@ -569,6 +787,15 @@ function InstallOrUpdate-BorealisAgent {
} }
} }
Run-Step "Provision Ansible Execution Environment" {
Ensure-AnsibleExecutionEnvironment `
-ProjectRoot $scriptDir `
-PythonBootstrapExe $pythonExe `
-RequirementsPath $ansibleEeRequirementsPath `
-ExpectedVersion $script:AnsibleExecutionEnvironmentVersion `
-LogName 'Install.log'
}
Run-Step "Configure Agent Settings" { Run-Step "Configure Agent Settings" {
$settingsDir = Join-Path $scriptDir 'Agent\Borealis\Settings' $settingsDir = Join-Path $scriptDir 'Agent\Borealis\Settings'
$oldSettingsDir = Join-Path $scriptDir 'Agent\Settings' $oldSettingsDir = Join-Path $scriptDir 'Agent\Settings'

View File

@@ -229,18 +229,55 @@ def detect_agent_os():
return "Unknown" return "Unknown"
def _ansible_ee_version():
try:
root = _project_root()
meta_path = os.path.join(root, 'Ansible_EE', 'metadata.json')
if os.path.isfile(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict):
for key in ('version', 'ansible_ee_ver', 'ansible_ee_version'):
value = data.get(key)
if isinstance(value, (str, int, float)):
text = str(value).strip()
if text:
return text
except Exception:
pass
version_txt = os.path.join(root, 'Ansible_EE', 'version.txt')
if os.path.isfile(version_txt):
try:
raw = Path(version_txt).read_text(encoding='utf-8')
if raw:
text = raw.splitlines()[0].strip()
if text:
return text
except Exception:
pass
except Exception:
pass
return ''
def collect_summary(CONFIG): def collect_summary(CONFIG):
try: try:
hostname = socket.gethostname() hostname = socket.gethostname()
return { summary = {
'hostname': hostname, 'hostname': hostname,
'os': detect_agent_os(), 'os': detect_agent_os(),
'username': os.environ.get('USERNAME') or os.environ.get('USER') or '', 'username': os.environ.get('USERNAME') or os.environ.get('USER') or '',
'domain': os.environ.get('USERDOMAIN') or '', 'domain': os.environ.get('USERDOMAIN') or '',
'uptime_sec': int(time.time() - psutil.boot_time()) if psutil else None, 'uptime_sec': int(time.time() - psutil.boot_time()) if psutil else None,
} }
summary['ansible_ee_ver'] = _ansible_ee_version()
return summary
except Exception: except Exception:
return {'hostname': socket.gethostname()} return {
'hostname': socket.gethostname(),
'ansible_ee_ver': _ansible_ee_version(),
}
def _project_root(): def _project_root():

View File

@@ -8,6 +8,7 @@ import json
import socket import socket
import subprocess import subprocess
import base64 import base64
from pathlib import Path
from typing import Optional from typing import Optional
try: try:
@@ -53,6 +54,62 @@ 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 _ansible_ee_root():
candidates = []
try:
candidates.append(os.path.join(_project_root(), 'Agent', 'Ansible_EE'))
except Exception:
pass
try:
candidates.append(os.path.join(_agent_root(), 'Ansible_EE'))
except Exception:
pass
for path in candidates:
if path and os.path.isdir(path):
return path
return None
def _ansible_ee_metadata():
root = _ansible_ee_root()
if not root:
return {}
meta_path = os.path.join(root, 'metadata.json')
if not os.path.isfile(meta_path):
return {}
try:
with open(meta_path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except Exception:
return {}
return {}
def _ansible_ee_version():
meta = _ansible_ee_metadata()
for key in ('version', 'ansible_ee_ver', 'ansible_ee_version'):
value = meta.get(key) if isinstance(meta, dict) else None
if isinstance(value, (str, int, float)):
text = str(value).strip()
if text:
return text
root = _ansible_ee_root()
if root:
txt_path = os.path.join(root, 'version.txt')
if os.path.isfile(txt_path):
try:
raw = Path(txt_path).read_text(encoding='utf-8')
if raw:
text = raw.splitlines()[0].strip()
if text:
return text
except Exception:
pass
return ''
def _decode_base64_text(value): def _decode_base64_text(value):
if not isinstance(value, str): if not isinstance(value, str):
return None return None
@@ -99,17 +156,38 @@ def _agent_root():
def _scripts_bin(): def _scripts_bin():
# Return the venv Scripts (Windows) or bin (POSIX) path adjacent to Borealis # Return the venv Scripts (Windows) or bin (POSIX) path adjacent to Borealis
candidates = []
ee_root = _ansible_ee_root()
if ee_root:
candidates.extend(
[
os.path.join(ee_root, 'Scripts'),
os.path.join(ee_root, 'bin'),
]
)
agent_root = _agent_root() agent_root = _agent_root()
candidates = [ candidates.extend(
[
os.path.join(agent_root, 'Scripts'), # Windows venv os.path.join(agent_root, 'Scripts'), # Windows venv
os.path.join(agent_root, 'bin'), # POSIX venv os.path.join(agent_root, 'bin'), # POSIX venv
] ]
)
for base in candidates: for base in candidates:
if os.path.isdir(base): if os.path.isdir(base):
return base return base
return None return None
def _ee_support_path():
root = _ansible_ee_root()
if not root:
return None
support = os.path.join(root, 'support')
if os.path.isdir(support):
return support
return None
def _ansible_playbook_cmd(): def _ansible_playbook_cmd():
exe = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook' exe = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook'
sdir = _scripts_bin() sdir = _scripts_bin()
@@ -137,10 +215,19 @@ def _collections_dir():
return base return base
def _venv_python(): def _venv_python():
ee_root = _ansible_ee_root()
if ee_root:
ee_candidates = [
os.path.join(ee_root, 'Scripts', 'python.exe'),
os.path.join(ee_root, 'Scripts', 'python3.exe'),
os.path.join(ee_root, 'bin', 'python3'),
os.path.join(ee_root, 'bin', 'python'),
]
for cand in ee_candidates:
if os.path.isfile(cand):
return cand
try: try:
sdir = _scripts_bin() sdir = os.path.join(_agent_root(), 'Scripts' if os.name == 'nt' else 'bin')
if not sdir:
return None
cand = os.path.join(sdir, 'python.exe' if os.name == 'nt' else 'python3') cand = os.path.join(sdir, 'python.exe' if os.name == 'nt' else 'python3')
return cand if os.path.isfile(cand) else None return cand if os.path.isfile(cand) else None
except Exception: except Exception:
@@ -175,50 +262,108 @@ class Role:
return os.path.join(tmp_dir, 'ansible_bootstrap.json') return os.path.join(tmp_dir, 'ansible_bootstrap.json')
def _detect_missing_modules(self) -> dict: def _detect_missing_modules(self) -> dict:
"""Return any required modules that the execution environment lacks."""
missing = {} missing = {}
for module, spec in REQUIRED_MODULES.items():
python_exe = _venv_python()
if not python_exe or not os.path.isfile(python_exe):
missing['python'] = 'execution-environment python missing'
return missing
module_names = sorted(REQUIRED_MODULES.keys())
probe = (
"import importlib.util, sys;"
f"mods={module_names!r};"
"missing=[m for m in mods if importlib.util.find_spec(m) is None];"
"sys.stdout.write('\\n'.join(missing))"
)
try: try:
__import__(module) completed = subprocess.run(
[python_exe, '-c', probe],
check=True,
capture_output=True,
text=True,
)
except Exception: except Exception:
missing[module] = spec for name in module_names:
missing[name] = REQUIRED_MODULES[name]
return missing
stdout = (completed.stdout or '').strip()
if stdout:
for name in stdout.splitlines():
mod = name.strip()
if mod and mod in REQUIRED_MODULES:
missing[mod] = REQUIRED_MODULES[mod]
return missing return missing
def _bootstrap_ansible_sync(self) -> bool: def _bootstrap_ansible_sync(self) -> bool:
missing = self._detect_missing_modules() missing = self._detect_missing_modules()
if not missing: if missing:
return True self._ansible_log(
specs = sorted({spec for spec in missing.values() if spec}) f"[bootstrap] required agent modules missing: {', '.join(sorted(missing.keys()))}",
python_exe = _venv_python() or sys.executable error=True,
if not python_exe: )
self._ansible_log('[bootstrap] python executable not found for pip install', error=True)
return False return False
cmd = [python_exe, '-m', 'pip', 'install', '--disable-pip-version-check'] + specs ee_root = _ansible_ee_root()
self._ansible_log(f"[bootstrap] ensuring modules via pip: {', '.join(specs)}") if not ee_root or not os.path.isdir(ee_root):
try: self._ansible_log('[bootstrap] execution environment folder Agent/Ansible_EE not found', error=True)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
except Exception as exc:
self._ansible_log(f"[bootstrap] pip install exception: {exc}", error=True)
return False return False
if result.returncode != 0:
err_tail = (result.stderr or '').strip() scripts_dir = _scripts_bin()
if len(err_tail) > 500: exe_name = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook'
err_tail = err_tail[-500:] playbook_path = None
self._ansible_log(f"[bootstrap] pip install failed rc={result.returncode} err={err_tail}", error=True) if scripts_dir:
candidate = os.path.join(scripts_dir, exe_name)
if os.path.isfile(candidate):
playbook_path = candidate
if not playbook_path:
self._ansible_log('[bootstrap] ansible-playbook executable missing in execution environment', error=True)
return False return False
remaining = self._detect_missing_modules()
if remaining: python_exe = _venv_python()
self._ansible_log(f"[bootstrap] modules still missing after install: {', '.join(sorted(remaining.keys()))}", error=True) if not python_exe or not os.path.isfile(python_exe):
self._ansible_log('[bootstrap] execution environment python not found', error=True)
return False return False
try:
marker = self._bootstrap_marker_path() env_path = os.environ.get('PATH') or ''
payload = { bin_dir = os.path.dirname(playbook_path)
'timestamp': int(time.time()), if bin_dir:
'modules': specs, segments = [seg for seg in env_path.split(os.pathsep) if seg]
} if bin_dir not in segments:
with open(marker, 'w', encoding='utf-8') as fh: os.environ['PATH'] = bin_dir + (os.pathsep + env_path if env_path else '')
json.dump(payload, fh)
except Exception: collections_dir = os.path.join(ee_root, 'collections')
pass if os.path.isdir(collections_dir):
existing = os.environ.get('ANSIBLE_COLLECTIONS_PATHS') or ''
paths = [seg for seg in existing.split(os.pathsep) if seg]
if collections_dir not in paths:
os.environ['ANSIBLE_COLLECTIONS_PATHS'] = (
collections_dir if not existing else collections_dir + os.pathsep + existing
)
os.environ['BOREALIS_ANSIBLE_EE_ROOT'] = ee_root
os.environ['BOREALIS_ANSIBLE_EE_PYTHON'] = python_exe
version = _ansible_ee_version()
if version:
self._ansible_log(f"[bootstrap] using execution environment version {version}")
support_dir = _ee_support_path()
if support_dir:
existing_pp = os.environ.get('PYTHONPATH') or ''
paths = [seg for seg in existing_pp.split(os.pathsep) if seg]
if support_dir not in paths:
os.environ['PYTHONPATH'] = (
support_dir
if not existing_pp
else support_dir + os.pathsep + existing_pp
)
os.environ['BOREALIS_ANSIBLE_EE_SUPPORT'] = support_dir
return True return True
async def _ensure_ansible_ready(self) -> bool: async def _ensure_ansible_ready(self) -> bool:

View File

@@ -0,0 +1,8 @@
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Agent/ansible-ee-requirements.txt
# Ansible execution environment dependencies
ansible-core==2.16.7
ansible-runner==2.3.5
pywinrm==0.4.3
requests-credssp==2.0.0
requests-ntlm==1.2.0
pypsrp==0.8.1

View File

@@ -0,0 +1,2 @@
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Agent/ansible-ee-version.txt
1.0.0

View File

@@ -429,6 +429,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const [credentialLoading, setCredentialLoading] = useState(false); const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialError, setCredentialError] = useState(""); const [credentialError, setCredentialError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState(""); const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const loadCredentials = useCallback(async () => { const loadCredentials = useCallback(async () => {
setCredentialLoading(true); setCredentialLoading(true);
@@ -453,6 +454,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
}, [loadCredentials]); }, [loadCredentials]);
const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]); const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]);
const handleExecContextChange = useCallback((value) => {
const normalized = String(value || "system").toLowerCase();
setExecContext(normalized);
if (normalized === "winrm") {
setUseSvcAccount(true);
setSelectedCredentialId("");
} else {
setUseSvcAccount(false);
}
}, []);
const filteredCredentials = useMemo(() => { const filteredCredentials = useMemo(() => {
if (!remoteExec) return credentials; if (!remoteExec) return credentials;
const target = execContext === "winrm" ? "winrm" : "ssh"; const target = execContext === "winrm" ? "winrm" : "ssh";
@@ -463,6 +474,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
if (!remoteExec) { if (!remoteExec) {
return; return;
} }
if (execContext === "winrm" && useSvcAccount) {
setSelectedCredentialId("");
return;
}
if (!filteredCredentials.length) { if (!filteredCredentials.length) {
setSelectedCredentialId(""); setSelectedCredentialId("");
return; return;
@@ -470,7 +485,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) { if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(filteredCredentials[0].id)); setSelectedCredentialId(String(filteredCredentials[0].id));
} }
}, [remoteExec, filteredCredentials, selectedCredentialId]); }, [remoteExec, filteredCredentials, selectedCredentialId, execContext, useSvcAccount]);
// dialogs state // dialogs state
const [addCompOpen, setAddCompOpen] = useState(false); const [addCompOpen, setAddCompOpen] = useState(false);
@@ -877,12 +892,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
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;
if (!base) return false; if (!base) return false;
if (remoteExec && !selectedCredentialId) return false; const needsCredential = remoteExec && !(execContext === "winrm" && useSvcAccount);
if (needsCredential && !selectedCredentialId) return false;
if (scheduleType !== "immediately") { if (scheduleType !== "immediately") {
return !!startDateTime; return !!startDateTime;
} }
return true; return true;
}, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId]); }, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId, execContext, useSvcAccount]);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const editing = !!(initialJob && initialJob.id); const editing = !!(initialJob && initialJob.id);
@@ -1358,6 +1374,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setExpiration(initialJob.expiration || "no_expire"); setExpiration(initialJob.expiration || "no_expire");
setExecContext(initialJob.execution_context || "system"); setExecContext(initialJob.execution_context || "system");
setSelectedCredentialId(initialJob.credential_id ? String(initialJob.credential_id) : ""); setSelectedCredentialId(initialJob.credential_id ? String(initialJob.credential_id) : "");
if ((initialJob.execution_context || "").toLowerCase() === "winrm") {
setUseSvcAccount(initialJob.use_service_account !== false);
} else {
setUseSvcAccount(false);
}
const comps = Array.isArray(initialJob.components) ? initialJob.components : []; const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
const hydrated = await hydrateExistingComponents(comps); const hydrated = await hydrateExistingComponents(comps);
if (!canceled) { if (!canceled) {
@@ -1369,6 +1390,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setComponents([]); setComponents([]);
setComponentVarErrors({}); setComponentVarErrors({});
setSelectedCredentialId(""); setSelectedCredentialId("");
setUseSvcAccount(true);
} }
}; };
hydrate(); hydrate();
@@ -1464,7 +1486,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
}; };
const handleCreate = async () => { const handleCreate = async () => {
if (remoteExec && !selectedCredentialId) { if (remoteExec && !(execContext === "winrm" && useSvcAccount) && !selectedCredentialId) {
alert("Please select a credential for this execution context."); alert("Please select a credential for this execution context.");
return; return;
} }
@@ -1496,7 +1518,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = 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 }, 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 },
execution_context: execContext, execution_context: execContext,
credential_id: remoteExec && selectedCredentialId ? Number(selectedCredentialId) : null credential_id: remoteExec && !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: execContext === "winrm" ? Boolean(useSvcAccount) : false
}; };
try { try {
const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", { const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", {
@@ -1726,7 +1749,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
<Select <Select
size="small" size="small"
value={execContext} value={execContext}
onChange={(e) => setExecContext(e.target.value)} onChange={(e) => handleExecContextChange(e.target.value)}
sx={{ minWidth: 320 }} sx={{ minWidth: 320 }}
> >
<MenuItem value="system">Run on agent as SYSTEM (device-local)</MenuItem> <MenuItem value="system">Run on agent as SYSTEM (device-local)</MenuItem>
@@ -1736,10 +1759,29 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Select> </Select>
{remoteExec && ( {remoteExec && (
<Box sx={{ mt: 2, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> <Box sx={{ mt: 2, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
{execContext === "winrm" && (
<FormControlLabel
control={
<Checkbox
checked={useSvcAccount}
onChange={(e) => {
const checked = e.target.checked;
setUseSvcAccount(checked);
if (checked) {
setSelectedCredentialId("");
} else if (!selectedCredentialId && filteredCredentials.length) {
setSelectedCredentialId(String(filteredCredentials[0].id));
}
}}
/>
}
label="Use Configured svcBorealis Account"
/>
)}
<FormControl <FormControl
size="small" size="small"
sx={{ minWidth: 320 }} sx={{ minWidth: 320 }}
disabled={credentialLoading || !filteredCredentials.length} disabled={credentialLoading || !filteredCredentials.length || (execContext === "winrm" && useSvcAccount)}
> >
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel> <InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select <Select
@@ -1771,7 +1813,12 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
{credentialError} {credentialError}
</Typography> </Typography>
)} )}
{!credentialLoading && !credentialError && !filteredCredentials.length && ( {execContext === "winrm" && useSvcAccount && (
<Typography variant="body2" sx={{ color: "#aaa" }}>
Runs with the agent&apos;s svcBorealis account.
</Typography>
)}
{!credentialLoading && !credentialError && !filteredCredentials.length && (!(execContext === "winrm" && useSvcAccount)) && (
<Typography variant="body2" sx={{ color: "#ff8080" }}> <Typography variant="body2" sx={{ color: "#ff8080" }}>
No {execContext === "winrm" ? "WinRM" : "SSH"} credentials available. Create one under Access Management &gt; Credentials. No {execContext === "winrm" ? "WinRM" : "SSH"} credentials available. Create one under Access Management &gt; Credentials.
</Typography> </Typography>

View File

@@ -91,6 +91,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const [credentialsLoading, setCredentialsLoading] = useState(false); const [credentialsLoading, setCredentialsLoading] = useState(false);
const [credentialsError, setCredentialsError] = useState(""); const [credentialsError, setCredentialsError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState(""); const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [variables, setVariables] = useState([]); const [variables, setVariables] = useState([]);
const [variableValues, setVariableValues] = useState({}); const [variableValues, setVariableValues] = useState({});
const [variableErrors, setVariableErrors] = useState({}); const [variableErrors, setVariableErrors] = useState({});
@@ -120,6 +121,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
setVariableValues({}); setVariableValues({});
setVariableErrors({}); setVariableErrors({});
setVariableStatus({ loading: false, error: "" }); setVariableStatus({ loading: false, error: "" });
setUseSvcAccount(true);
setSelectedCredentialId("");
loadTree(); loadTree();
} }
}, [open, loadTree]); }, [open, loadTree]);
@@ -136,7 +139,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const data = await resp.json(); const data = await resp.json();
if (canceled) return; if (canceled) return;
const list = Array.isArray(data?.credentials) const list = Array.isArray(data?.credentials)
? data.credentials.filter((cred) => String(cred.connection_type || "").toLowerCase() === "ssh") ? data.credentials.filter((cred) => {
const conn = String(cred.connection_type || "").toLowerCase();
return conn === "ssh" || conn === "winrm";
})
: []; : [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""))); list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setCredentials(list); setCredentials(list);
@@ -161,7 +167,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (mode !== "ansible") return; if (mode !== "ansible" || useSvcAccount) return;
if (!credentials.length) { if (!credentials.length) {
setSelectedCredentialId(""); setSelectedCredentialId("");
return; return;
@@ -169,7 +175,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) { if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(credentials[0].id)); setSelectedCredentialId(String(credentials[0].id));
} }
}, [mode, credentials, selectedCredentialId]); }, [mode, credentials, selectedCredentialId, useSvcAccount]);
const renderNodes = (nodes = []) => const renderNodes = (nodes = []) =>
nodes.map((n) => ( nodes.map((n) => (
@@ -342,7 +348,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
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 (mode === 'ansible' && !selectedCredentialId) { if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) {
setError("Select a credential to run this playbook."); setError("Select a credential to run this playbook.");
return; return;
} }
@@ -378,7 +384,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
playbook_path, playbook_path,
hostnames, hostnames,
variable_values: variableOverrides, variable_values: variableOverrides,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : null credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: Boolean(useSvcAccount)
}) })
}); });
} else { } else {
@@ -405,8 +412,11 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
} }
}; };
const credentialRequired = mode === "ansible"; const credentialRequired = mode === "ansible" && !useSvcAccount;
const disableRun = running || !selectedPath || (credentialRequired && (!selectedCredentialId || !credentials.length)); const disableRun =
running ||
!selectedPath ||
(credentialRequired && (!selectedCredentialId || !credentials.length));
return ( return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md" <Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
@@ -423,10 +433,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
</Typography> </Typography>
{mode === 'ansible' && ( {mode === 'ansible' && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={useSvcAccount}
onChange={(e) => {
const checked = e.target.checked;
setUseSvcAccount(checked);
if (checked) {
setSelectedCredentialId("");
} else if (!selectedCredentialId && credentials.length) {
setSelectedCredentialId(String(credentials[0].id));
}
}}
size="small"
/>
}
label="Use Configured svcBorealis Account"
sx={{ mr: 2 }}
/>
<FormControl <FormControl
size="small" size="small"
sx={{ minWidth: 260 }} sx={{ minWidth: 260 }}
disabled={credentialsLoading || !credentials.length} disabled={useSvcAccount || credentialsLoading || !credentials.length}
> >
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel> <InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select <Select
@@ -435,20 +464,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
onChange={(e) => setSelectedCredentialId(e.target.value)} onChange={(e) => setSelectedCredentialId(e.target.value)}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }} sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
> >
{credentials.map((cred) => ( {credentials.map((cred) => {
const conn = String(cred.connection_type || "").toUpperCase();
return (
<MenuItem key={cred.id} value={String(cred.id)}> <MenuItem key={cred.id} value={String(cred.id)}>
{cred.name} {cred.name}
{conn ? ` (${conn})` : ""}
</MenuItem> </MenuItem>
))} );
})}
</Select> </Select>
</FormControl> </FormControl>
{useSvcAccount && (
<Typography variant="body2" sx={{ color: "#aaa" }}>
Runs with the agent&apos;s svcBorealis account.
</Typography>
)}
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />} {credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
{!credentialsLoading && credentialsError && ( {!credentialsLoading && credentialsError && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography> <Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
)} )}
{!credentialsLoading && !credentialsError && !credentials.length && ( {!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
<Typography variant="body2" sx={{ color: "#ff8080" }}> <Typography variant="body2" sx={{ color: "#ff8080" }}>
No SSH credentials available. Create one under Access Management. No SSH or WinRM credentials available. Create one under Access Management.
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@@ -7,6 +7,10 @@ import re
import sqlite3 import sqlite3
from typing import Any, Dict, List, Optional, Tuple, Callable from typing import Any, Dict, List, Optional, Tuple, Callable
_WINRM_USERNAME_VAR = "__borealis_winrm_username"
_WINRM_PASSWORD_VAR = "__borealis_winrm_password"
_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport"
""" """
Job Scheduler module for Borealis Job Scheduler module for Borealis
@@ -54,6 +58,26 @@ def _decode_base64_text(value: Any) -> Optional[str]:
return decoded.decode("utf-8", errors="replace") return decoded.decode("utf-8", errors="replace")
def _inject_winrm_credential(
base_values: Optional[Dict[str, Any]],
credential: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
values: Dict[str, Any] = dict(base_values or {})
if not credential:
return values
username = str(credential.get("username") or "")
password = str(credential.get("password") or "")
metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {}
transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None
transport_str = str(transport or "ntlm").strip().lower() or "ntlm"
values[_WINRM_USERNAME_VAR] = username
values[_WINRM_PASSWORD_VAR] = password
values[_WINRM_TRANSPORT_VAR] = transport_str
return values
def _decode_script_content(value: Any, encoding_hint: str = "") -> str: def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
encoding = (encoding_hint or "").strip().lower() encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str): if isinstance(value, str):
@@ -311,6 +335,8 @@ class JobScheduler:
self._online_lookup: Optional[Callable[[], List[str]]] = None self._online_lookup: Optional[Callable[[], List[str]]] = None
# Optional callback to execute Ansible directly from the server # Optional callback to execute Ansible directly from the server
self._server_ansible_runner: Optional[Callable[..., str]] = None self._server_ansible_runner: Optional[Callable[..., str]] = None
# Optional callback to fetch stored credentials (with decrypted secrets)
self._credential_fetcher: Optional[Callable[[int], Optional[Dict[str, Any]]]] = None
# Ensure run-history table exists # Ensure run-history table exists
self._init_tables() self._init_tables()
@@ -485,6 +511,7 @@ class JobScheduler:
scheduled_run_row_id: int, scheduled_run_row_id: int,
run_mode: str, run_mode: str,
credential_id: Optional[int] = None, credential_id: Optional[int] = None,
use_service_account: bool = False,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
try: try:
import os, uuid import os, uuid
@@ -522,7 +549,24 @@ class JobScheduler:
variables = doc.get("variables") or [] variables = doc.get("variables") or []
files = doc.get("files") or [] files = doc.get("files") or []
run_mode_norm = (run_mode or "system").strip().lower() run_mode_norm = (run_mode or "system").strip().lower()
server_run = run_mode_norm in ("ssh", "winrm") server_run = run_mode_norm == "ssh"
agent_winrm = run_mode_norm == "winrm"
if agent_winrm and not use_service_account:
if not credential_id:
raise RuntimeError("WinRM execution requires a credential_id")
if not callable(self._credential_fetcher):
raise RuntimeError("Credential fetcher is not configured")
cred_detail = self._credential_fetcher(int(credential_id))
if not cred_detail:
raise RuntimeError("Credential not found")
try:
overrides_map = _inject_winrm_credential(overrides_map, cred_detail)
finally:
try:
cred_detail.clear() # type: ignore[attr-defined]
except Exception:
pass
# Record in activity_history for UI parity # Record in activity_history for UI parity
now = _now_ts() now = _now_ts()
@@ -743,6 +787,9 @@ class JobScheduler:
def _conn(self): def _conn(self):
return sqlite3.connect(self.db_path) return sqlite3.connect(self.db_path)
def set_credential_fetcher(self, fn: Optional[Callable[[int], Optional[Dict[str, Any]]]]):
self._credential_fetcher = fn
def _init_tables(self): def _init_tables(self):
conn = self._conn() conn = self._conn()
cur = conn.cursor() cur = conn.cursor()
@@ -954,7 +1001,7 @@ class JobScheduler:
pass pass
try: try:
cur.execute( cur.execute(
"SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC" "SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, use_service_account, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC"
) )
jobs = cur.fetchall() jobs = cur.fetchall()
except Exception: except Exception:
@@ -972,7 +1019,18 @@ class JobScheduler:
five_min = 300 five_min = 300
now_min = _now_minute() now_min = _now_minute()
for (job_id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at) in jobs: for (
job_id,
components_json,
targets_json,
schedule_type,
start_ts,
expiration,
execution_context,
credential_id,
use_service_account_flag,
created_at,
) in jobs:
try: try:
# Targets list for this job # Targets list for this job
try: try:
@@ -1008,6 +1066,9 @@ class JobScheduler:
continue continue
run_mode = (execution_context or "system").strip().lower() run_mode = (execution_context or "system").strip().lower()
job_credential_id = None job_credential_id = None
job_use_service_account = bool(use_service_account_flag)
if run_mode != "winrm":
job_use_service_account = False
try: try:
job_credential_id = int(credential_id) if credential_id is not None else None job_credential_id = int(credential_id) if credential_id is not None else None
except Exception: except Exception:
@@ -1098,7 +1159,7 @@ class JobScheduler:
run_row_id = c2.lastrowid or 0 run_row_id = c2.lastrowid or 0
conn2.commit() conn2.commit()
activity_links: List[Dict[str, Any]] = [] activity_links: List[Dict[str, Any]] = []
remote_requires_cred = run_mode in ("ssh", "winrm") remote_requires_cred = (run_mode == "ssh") or (run_mode == "winrm" and not job_use_service_account)
if remote_requires_cred and not job_credential_id: if remote_requires_cred and not job_credential_id:
err_msg = "Credential required for remote execution" err_msg = "Credential required for remote execution"
c2.execute( c2.execute(
@@ -1132,6 +1193,7 @@ class JobScheduler:
run_row_id, run_row_id,
run_mode, run_mode,
job_credential_id, job_credential_id,
job_use_service_account,
) )
if link and link.get("activity_id"): if link and link.get("activity_id"):
activity_links.append({ activity_links.append({
@@ -1243,9 +1305,10 @@ class JobScheduler:
"expiration": r[7] or "no_expire", "expiration": r[7] or "no_expire",
"execution_context": r[8] or "system", "execution_context": r[8] or "system",
"credential_id": r[9], "credential_id": r[9],
"enabled": bool(r[10] or 0), "use_service_account": bool(r[10] or 0),
"created_at": r[11] or 0, "enabled": bool(r[11] or 0),
"updated_at": r[12] or 0, "created_at": r[12] or 0,
"updated_at": r[13] or 0,
} }
# Attach computed status summary for latest occurrence # Attach computed status summary for latest occurrence
try: try:
@@ -1322,7 +1385,8 @@ class JobScheduler:
cur.execute( cur.execute(
""" """
SELECT id, name, components_json, targets_json, schedule_type, start_ts, SELECT id, name, components_json, targets_json, schedule_type, start_ts,
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at duration_stop_enabled, expiration, execution_context, credential_id,
use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs FROM scheduled_jobs
ORDER BY created_at DESC ORDER BY created_at DESC
""" """
@@ -1350,6 +1414,8 @@ class JobScheduler:
credential_id = int(credential_id) if credential_id is not None else None credential_id = int(credential_id) if credential_id is not None else None
except Exception: except Exception:
credential_id = None credential_id = None
use_service_account_raw = data.get("use_service_account")
use_service_account = 1 if (execution_context == "winrm" and (use_service_account_raw is None or bool(use_service_account_raw))) else 0
enabled = int(bool(data.get("enabled", True))) enabled = int(bool(data.get("enabled", True)))
if not name or not components or not targets: if not name or not components or not targets:
return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"} return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"}
@@ -1360,8 +1426,8 @@ class JobScheduler:
cur.execute( cur.execute(
""" """
INSERT INTO scheduled_jobs INSERT INTO scheduled_jobs
(name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at) (name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", """,
( (
name, name,
@@ -1373,6 +1439,7 @@ class JobScheduler:
expiration, expiration,
execution_context, execution_context,
credential_id, credential_id,
use_service_account,
enabled, enabled,
now, now,
now, now,
@@ -1383,7 +1450,7 @@ class JobScheduler:
cur.execute( cur.execute(
""" """
SELECT id, name, components_json, targets_json, schedule_type, start_ts, SELECT id, name, components_json, targets_json, schedule_type, start_ts,
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=? FROM scheduled_jobs WHERE id=?
""", """,
(job_id,), (job_id,),
@@ -1402,7 +1469,7 @@ class JobScheduler:
cur.execute( cur.execute(
""" """
SELECT id, name, components_json, targets_json, schedule_type, start_ts, SELECT id, name, components_json, targets_json, schedule_type, start_ts,
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=? FROM scheduled_jobs WHERE id=?
""", """,
(job_id,), (job_id,),
@@ -1435,7 +1502,10 @@ class JobScheduler:
if "expiration" in data or (data.get("duration") and "expiration" in data.get("duration")): if "expiration" in data or (data.get("duration") and "expiration" in data.get("duration")):
fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire" fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire"
if "execution_context" in data: if "execution_context" in data:
fields["execution_context"] = (data.get("execution_context") or "system").strip().lower() exec_ctx_val = (data.get("execution_context") or "system").strip().lower()
fields["execution_context"] = exec_ctx_val
if exec_ctx_val != "winrm":
fields["use_service_account"] = 0
if "credential_id" in data: if "credential_id" in data:
cred_val = data.get("credential_id") cred_val = data.get("credential_id")
if cred_val in (None, "", "null"): if cred_val in (None, "", "null"):
@@ -1445,6 +1515,8 @@ class JobScheduler:
fields["credential_id"] = int(cred_val) fields["credential_id"] = int(cred_val)
except Exception: except Exception:
fields["credential_id"] = None fields["credential_id"] = None
if "use_service_account" in data:
fields["use_service_account"] = 1 if bool(data.get("use_service_account")) else 0
if "enabled" in data: if "enabled" in data:
fields["enabled"] = int(bool(data.get("enabled"))) fields["enabled"] = int(bool(data.get("enabled")))
if not fields: if not fields:
@@ -1462,7 +1534,7 @@ class JobScheduler:
cur.execute( cur.execute(
""" """
SELECT id, name, components_json, targets_json, schedule_type, start_ts, SELECT id, name, components_json, targets_json, schedule_type, start_ts,
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=? FROM scheduled_jobs WHERE id=?
""", """,
(job_id,), (job_id,),
@@ -1486,7 +1558,7 @@ class JobScheduler:
return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"} return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"}
conn.commit() conn.commit()
cur.execute( cur.execute(
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?", "SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?",
(job_id,), (job_id,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -1738,3 +1810,7 @@ def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]):
def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]): def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]):
scheduler._server_ansible_runner = fn scheduler._server_ansible_runner = fn
def set_credential_fetcher(scheduler: JobScheduler, fn: Callable[[int], Optional[Dict[str, Any]]]):
scheduler._credential_fetcher = fn

View File

@@ -329,6 +329,7 @@ from Python_API_Endpoints.script_engines import run_powershell_script
from job_scheduler import register as register_job_scheduler from job_scheduler import register as register_job_scheduler
from job_scheduler import set_online_lookup as scheduler_set_online_lookup from job_scheduler import set_online_lookup as scheduler_set_online_lookup
from job_scheduler import set_server_ansible_runner as scheduler_set_server_runner from job_scheduler import set_server_ansible_runner as scheduler_set_server_runner
from job_scheduler import set_credential_fetcher as scheduler_set_credential_fetcher
# ============================================================================= # =============================================================================
# Section: Runtime Stack Configuration # Section: Runtime Stack Configuration
@@ -1859,6 +1860,11 @@ def _ensure_ansible_workspace() -> str:
return _ANSIBLE_WORKSPACE_DIR return _ANSIBLE_WORKSPACE_DIR
_WINRM_USERNAME_VAR = "__borealis_winrm_username"
_WINRM_PASSWORD_VAR = "__borealis_winrm_password"
_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport"
def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any]]: def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any]]:
try: try:
conn = _db_conn() conn = _db_conn()
@@ -1876,7 +1882,8 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any
private_key_passphrase_encrypted, private_key_passphrase_encrypted,
become_method, become_method,
become_username, become_username,
become_password_encrypted become_password_encrypted,
metadata_json
FROM credentials FROM credentials
WHERE id=? WHERE id=?
""", """,
@@ -1900,8 +1907,40 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any
"become_method": _normalize_become_method(row[8]), "become_method": _normalize_become_method(row[8]),
"become_username": row[9] or "", "become_username": row[9] or "",
"become_password": _decrypt_secret(row[10]) if row[10] else "", "become_password": _decrypt_secret(row[10]) if row[10] else "",
"metadata": {},
} }
try:
meta_json = row[11] if len(row) > 11 else None
if meta_json:
meta = json.loads(meta_json)
if isinstance(meta, dict):
cred["metadata"] = meta
except Exception:
pass
return cred
def _inject_winrm_credential(
base_values: Optional[Dict[str, Any]],
credential: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
values: Dict[str, Any] = dict(base_values or {})
if not credential:
return values
username = str(credential.get("username") or "")
password = str(credential.get("password") or "")
metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {}
transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None
transport_str = str(transport or "ntlm").strip().lower() or "ntlm"
values[_WINRM_USERNAME_VAR] = username
values[_WINRM_PASSWORD_VAR] = password
values[_WINRM_TRANSPORT_VAR] = transport_str
return values
def _emit_ansible_recap_from_row(row): def _emit_ansible_recap_from_row(row):
if not row: if not row:
@@ -3514,6 +3553,7 @@ _DEVICE_TABLE_COLUMNS = [
"operating_system", "operating_system",
"uptime", "uptime",
"agent_id", "agent_id",
"ansible_ee_ver",
"connection_type", "connection_type",
"connection_endpoint", "connection_endpoint",
] ]
@@ -3603,6 +3643,7 @@ def _assemble_device_snapshot(record: Dict[str, Any]) -> Dict[str, Any]:
"created": _ts_to_human(created_ts), "created": _ts_to_human(created_ts),
"connection_type": _clean_device_str(record.get("connection_type")) or "", "connection_type": _clean_device_str(record.get("connection_type")) or "",
"connection_endpoint": _clean_device_str(record.get("connection_endpoint")) or "", "connection_endpoint": _clean_device_str(record.get("connection_endpoint")) or "",
"ansible_ee_ver": _clean_device_str(record.get("ansible_ee_ver")) or "",
} }
details = { details = {
@@ -3747,6 +3788,7 @@ def _extract_device_columns(details: Dict[str, Any]) -> Dict[str, Any]:
) )
payload["uptime"] = _coerce_int(uptime_value) payload["uptime"] = _coerce_int(uptime_value)
payload["agent_id"] = _clean_device_str(summary.get("agent_id")) payload["agent_id"] = _clean_device_str(summary.get("agent_id"))
payload["ansible_ee_ver"] = _clean_device_str(summary.get("ansible_ee_ver"))
payload["connection_type"] = _clean_device_str( payload["connection_type"] = _clean_device_str(
summary.get("connection_type") summary.get("connection_type")
or summary.get("remote_type") or summary.get("remote_type")
@@ -3815,9 +3857,10 @@ def _device_upsert(
operating_system, operating_system,
uptime, uptime,
agent_id, agent_id,
ansible_ee_ver,
connection_type, connection_type,
connection_endpoint connection_endpoint
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(hostname) DO UPDATE SET ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description, description=excluded.description,
created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at), created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at),
@@ -3838,6 +3881,7 @@ def _device_upsert(
operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system), operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system),
uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime), uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime),
agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id), agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id),
ansible_ee_ver=COALESCE(NULLIF(excluded.ansible_ee_ver, ''), {DEVICE_TABLE}.ansible_ee_ver),
connection_type=COALESCE(NULLIF(excluded.connection_type, ''), {DEVICE_TABLE}.connection_type), connection_type=COALESCE(NULLIF(excluded.connection_type, ''), {DEVICE_TABLE}.connection_type),
connection_endpoint=COALESCE(NULLIF(excluded.connection_endpoint, ''), {DEVICE_TABLE}.connection_endpoint) connection_endpoint=COALESCE(NULLIF(excluded.connection_endpoint, ''), {DEVICE_TABLE}.connection_endpoint)
""" """
@@ -3863,6 +3907,7 @@ def _device_upsert(
column_values.get("operating_system"), column_values.get("operating_system"),
column_values.get("uptime"), column_values.get("uptime"),
column_values.get("agent_id"), column_values.get("agent_id"),
column_values.get("ansible_ee_ver"),
column_values.get("connection_type"), column_values.get("connection_type"),
column_values.get("connection_endpoint"), column_values.get("connection_endpoint"),
] ]
@@ -4160,7 +4205,10 @@ def init_db():
last_user TEXT, last_user TEXT,
operating_system TEXT, operating_system TEXT,
uptime INTEGER, uptime INTEGER,
agent_id TEXT agent_id TEXT,
ansible_ee_ver TEXT,
connection_type TEXT,
connection_endpoint TEXT
) )
""" """
) )
@@ -4193,6 +4241,7 @@ def init_db():
_ensure_column("operating_system", "TEXT") _ensure_column("operating_system", "TEXT")
_ensure_column("uptime", "INTEGER") _ensure_column("uptime", "INTEGER")
_ensure_column("agent_id", "TEXT") _ensure_column("agent_id", "TEXT")
_ensure_column("ansible_ee_ver", "TEXT")
_ensure_column("connection_type", "TEXT") _ensure_column("connection_type", "TEXT")
_ensure_column("connection_endpoint", "TEXT") _ensure_column("connection_endpoint", "TEXT")
@@ -4274,6 +4323,7 @@ def init_db():
operating_system TEXT, operating_system TEXT,
uptime INTEGER, uptime INTEGER,
agent_id TEXT, agent_id TEXT,
ansible_ee_ver TEXT,
connection_type TEXT, connection_type TEXT,
connection_endpoint TEXT connection_endpoint TEXT
) )
@@ -4481,6 +4531,7 @@ def init_db():
expiration TEXT, expiration TEXT,
execution_context TEXT NOT NULL, execution_context TEXT NOT NULL,
credential_id INTEGER, credential_id INTEGER,
use_service_account INTEGER NOT NULL DEFAULT 1,
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
created_at INTEGER, created_at INTEGER,
updated_at INTEGER updated_at INTEGER
@@ -4492,6 +4543,8 @@ def init_db():
sj_cols = [row[1] for row in cur.fetchall()] sj_cols = [row[1] for row in cur.fetchall()]
if "credential_id" not in sj_cols: if "credential_id" not in sj_cols:
cur.execute("ALTER TABLE scheduled_jobs ADD COLUMN credential_id INTEGER") cur.execute("ALTER TABLE scheduled_jobs ADD COLUMN credential_id INTEGER")
if "use_service_account" not in sj_cols:
cur.execute("ALTER TABLE scheduled_jobs ADD COLUMN use_service_account INTEGER NOT NULL DEFAULT 1")
except Exception: except Exception:
pass pass
conn.commit() conn.commit()
@@ -4553,6 +4606,7 @@ ensure_default_admin()
job_scheduler = register_job_scheduler(app, socketio, DB_PATH) job_scheduler = register_job_scheduler(app, socketio, DB_PATH)
scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run) scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run)
scheduler_set_credential_fetcher(job_scheduler, _fetch_credential_with_secrets)
job_scheduler.start() job_scheduler.start()
# Provide scheduler with online device lookup based on registered agents # Provide scheduler with online device lookup based on registered agents
@@ -6359,20 +6413,46 @@ def ansible_quick_run():
rel_path = (data.get("playbook_path") or "").strip() rel_path = (data.get("playbook_path") or "").strip()
hostnames = data.get("hostnames") or [] hostnames = data.get("hostnames") or []
credential_id = data.get("credential_id") credential_id = data.get("credential_id")
use_service_account_raw = data.get("use_service_account")
if not rel_path or not isinstance(hostnames, list) or not hostnames: if not rel_path or not isinstance(hostnames, list) or not hostnames:
_ansible_log_server(f"[quick_run] invalid payload rel_path='{rel_path}' hostnames={hostnames}") _ansible_log_server(f"[quick_run] invalid payload rel_path='{rel_path}' hostnames={hostnames}")
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400 return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
server_mode = False server_mode = False
cred_id_int = None cred_id_int = None
credential_detail: Optional[Dict[str, Any]] = None
overrides_raw = data.get("variable_values")
variable_values: Dict[str, Any] = {}
if isinstance(overrides_raw, dict):
for key, val in overrides_raw.items():
name = str(key or "").strip()
if not name:
continue
variable_values[name] = val
if credential_id not in (None, "", "null"): if credential_id not in (None, "", "null"):
try: try:
cred_id_int = int(credential_id) cred_id_int = int(credential_id)
if cred_id_int <= 0: if cred_id_int <= 0:
cred_id_int = None cred_id_int = None
else:
server_mode = True
except Exception: except Exception:
return jsonify({"error": "Invalid credential_id"}), 400 return jsonify({"error": "Invalid credential_id"}), 400
if use_service_account_raw is None:
use_service_account = cred_id_int is None
else:
use_service_account = bool(use_service_account_raw)
if use_service_account:
cred_id_int = None
credential_detail = None
if cred_id_int:
credential_detail = _fetch_credential_with_secrets(cred_id_int)
if not credential_detail:
return jsonify({"error": "Credential not found"}), 404
conn_type = (credential_detail.get("connection_type") or "ssh").lower()
if conn_type in ("ssh", "linux", "unix"):
server_mode = True
elif conn_type in ("winrm", "psrp"):
variable_values = _inject_winrm_credential(variable_values, credential_detail)
else:
return jsonify({"error": f"Credential connection '{conn_type}' not supported"}), 400
try: try:
root, abs_path, _ = _resolve_assembly_path('ansible', rel_path) root, abs_path, _ = _resolve_assembly_path('ansible', rel_path)
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
@@ -6384,28 +6464,9 @@ def ansible_quick_run():
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 []
friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path) friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path)
overrides_raw = data.get("variable_values")
variable_values = {}
if isinstance(overrides_raw, dict):
for key, val in overrides_raw.items():
name = str(key or "").strip()
if not name:
continue
variable_values[name] = val
if server_mode and not cred_id_int: if server_mode and not cred_id_int:
return jsonify({"error": "credential_id is required for server-side execution"}), 400 return jsonify({"error": "credential_id is required for server-side execution"}), 400
if server_mode:
cred = _fetch_credential_with_secrets(cred_id_int)
if not cred:
return jsonify({"error": "Credential not found"}), 404
conn_type = (cred.get("connection_type") or "ssh").lower()
if conn_type not in ("ssh",):
return jsonify({"error": f"Credential connection '{conn_type}' not supported for server execution"}), 400
# Avoid keeping decrypted secrets in memory longer than necessary
del cred
results = [] results = []
for host in hostnames: for host in hostnames:
# Create activity_history row so UI shows running state and can receive recap mirror # Create activity_history row so UI shows running state and can receive recap mirror
@@ -6499,6 +6560,9 @@ def ansible_quick_run():
except Exception: except Exception:
pass pass
results.append({"hostname": host, "run_id": run_id, "status": "Failed", "activity_job_id": job_id, "error": str(ex)}) results.append({"hostname": host, "run_id": run_id, "status": "Failed", "activity_job_id": job_id, "error": str(ex)})
if credential_detail is not None:
# Remove decrypted secrets from scope as soon as possible
credential_detail.clear()
return jsonify({"results": results}) return jsonify({"results": results})
except ValueError as ve: except ValueError as ve:
return jsonify({"error": str(ve)}), 400 return jsonify({"error": str(ve)}), 400

View File

@@ -497,6 +497,8 @@ function Invoke-BorealisUpdate {
$preservePath = Join-Path $scriptDir "Data\Server\Python_API_Endpoints\Tesseract-OCR" $preservePath = Join-Path $scriptDir "Data\Server\Python_API_Endpoints\Tesseract-OCR"
$preserveBackupPath = Join-Path $scriptDir "Update_Staging\Tesseract-OCR" $preserveBackupPath = Join-Path $scriptDir "Update_Staging\Tesseract-OCR"
$ansibleEePath = Join-Path $scriptDir "Agent\Ansible_EE"
$ansibleEeBackupPath = Join-Path $scriptDir "Update_Staging\Ansible_EE"
Run-Step "Updating: Move Tesseract-OCR Folder Somewhere Safe to Restore Later" { Run-Step "Updating: Move Tesseract-OCR Folder Somewhere Safe to Restore Later" {
if (Test-Path $preservePath) { if (Test-Path $preservePath) {
@@ -506,6 +508,17 @@ function Invoke-BorealisUpdate {
} }
} }
Run-Step "Updating: Preserve Ansible Execution Environment" {
if (Test-Path $ansibleEePath) {
$stagingPath = Join-Path $scriptDir "Update_Staging"
if (-not (Test-Path $stagingPath)) { New-Item -ItemType Directory -Force -Path $stagingPath | Out-Null }
if (Test-Path $ansibleEeBackupPath) {
Remove-Item -Path $ansibleEeBackupPath -Recurse -Force -ErrorAction SilentlyContinue
}
Move-Item -Path $ansibleEePath -Destination $ansibleEeBackupPath -Force
}
}
Run-Step "Updating: Clean Up Folders to Prepare for Update" { Run-Step "Updating: Clean Up Folders to Prepare for Update" {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue ` Remove-Item -Recurse -Force -ErrorAction SilentlyContinue `
(Join-Path $scriptDir "Data"), ` (Join-Path $scriptDir "Data"), `
@@ -575,6 +588,14 @@ function Invoke-BorealisUpdate {
} }
} }
Run-Step "Updating: Restore Ansible Execution Environment" {
$restorePath = Join-Path $scriptDir "Agent"
if (Test-Path $ansibleEeBackupPath) {
if (-not (Test-Path $restorePath)) { New-Item -ItemType Directory -Force -Path $restorePath | Out-Null }
Move-Item -Path $ansibleEeBackupPath -Destination $restorePath -Force
}
}
Run-Step "Updating: Clean Up Update Staging Folder" { Run-Step "Updating: Clean Up Update Staging Folder" {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $stagingPath Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $stagingPath
} }