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"
$npmCmd = Join-Path (Split-Path $nodeExe) 'npm.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"
$nodeInstallDir = Join-Path $depsRoot "NodeJS"
$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 {
param([string]$ScriptRoot)
$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" {
$settingsDir = Join-Path $scriptDir 'Agent\Borealis\Settings'
$oldSettingsDir = Join-Path $scriptDir 'Agent\Settings'

View File

@@ -229,18 +229,55 @@ def detect_agent_os():
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):
try:
hostname = socket.gethostname()
return {
summary = {
'hostname': hostname,
'os': detect_agent_os(),
'username': os.environ.get('USERNAME') or os.environ.get('USER') or '',
'domain': os.environ.get('USERDOMAIN') or '',
'uptime_sec': int(time.time() - psutil.boot_time()) if psutil else None,
}
summary['ansible_ee_ver'] = _ansible_ee_version()
return summary
except Exception:
return {'hostname': socket.gethostname()}
return {
'hostname': socket.gethostname(),
'ansible_ee_ver': _ansible_ee_version(),
}
def _project_root():

View File

@@ -8,6 +8,7 @@ import json
import socket
import subprocess
import base64
from pathlib import Path
from typing import Optional
try:
@@ -53,6 +54,62 @@ def _project_root():
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):
if not isinstance(value, str):
return None
@@ -99,17 +156,38 @@ def _agent_root():
def _scripts_bin():
# 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()
candidates = [
os.path.join(agent_root, 'Scripts'), # Windows venv
os.path.join(agent_root, 'bin'), # POSIX venv
]
candidates.extend(
[
os.path.join(agent_root, 'Scripts'), # Windows venv
os.path.join(agent_root, 'bin'), # POSIX venv
]
)
for base in candidates:
if os.path.isdir(base):
return base
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():
exe = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook'
sdir = _scripts_bin()
@@ -137,10 +215,19 @@ def _collections_dir():
return base
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:
sdir = _scripts_bin()
if not sdir:
return None
sdir = os.path.join(_agent_root(), 'Scripts' if os.name == 'nt' else 'bin')
cand = os.path.join(sdir, 'python.exe' if os.name == 'nt' else 'python3')
return cand if os.path.isfile(cand) else None
except Exception:
@@ -175,50 +262,108 @@ class Role:
return os.path.join(tmp_dir, 'ansible_bootstrap.json')
def _detect_missing_modules(self) -> dict:
"""Return any required modules that the execution environment lacks."""
missing = {}
for module, spec in REQUIRED_MODULES.items():
try:
__import__(module)
except Exception:
missing[module] = spec
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:
completed = subprocess.run(
[python_exe, '-c', probe],
check=True,
capture_output=True,
text=True,
)
except Exception:
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
def _bootstrap_ansible_sync(self) -> bool:
missing = self._detect_missing_modules()
if not missing:
return True
specs = sorted({spec for spec in missing.values() if spec})
python_exe = _venv_python() or sys.executable
if not python_exe:
self._ansible_log('[bootstrap] python executable not found for pip install', error=True)
if missing:
self._ansible_log(
f"[bootstrap] required agent modules missing: {', '.join(sorted(missing.keys()))}",
error=True,
)
return False
cmd = [python_exe, '-m', 'pip', 'install', '--disable-pip-version-check'] + specs
self._ansible_log(f"[bootstrap] ensuring modules via pip: {', '.join(specs)}")
try:
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)
ee_root = _ansible_ee_root()
if not ee_root or not os.path.isdir(ee_root):
self._ansible_log('[bootstrap] execution environment folder Agent/Ansible_EE not found', error=True)
return False
if result.returncode != 0:
err_tail = (result.stderr or '').strip()
if len(err_tail) > 500:
err_tail = err_tail[-500:]
self._ansible_log(f"[bootstrap] pip install failed rc={result.returncode} err={err_tail}", error=True)
scripts_dir = _scripts_bin()
exe_name = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook'
playbook_path = None
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
remaining = self._detect_missing_modules()
if remaining:
self._ansible_log(f"[bootstrap] modules still missing after install: {', '.join(sorted(remaining.keys()))}", error=True)
python_exe = _venv_python()
if not python_exe or not os.path.isfile(python_exe):
self._ansible_log('[bootstrap] execution environment python not found', error=True)
return False
try:
marker = self._bootstrap_marker_path()
payload = {
'timestamp': int(time.time()),
'modules': specs,
}
with open(marker, 'w', encoding='utf-8') as fh:
json.dump(payload, fh)
except Exception:
pass
env_path = os.environ.get('PATH') or ''
bin_dir = os.path.dirname(playbook_path)
if bin_dir:
segments = [seg for seg in env_path.split(os.pathsep) if seg]
if bin_dir not in segments:
os.environ['PATH'] = bin_dir + (os.pathsep + env_path if env_path else '')
collections_dir = os.path.join(ee_root, 'collections')
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
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 [credentialError, setCredentialError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const loadCredentials = useCallback(async () => {
setCredentialLoading(true);
@@ -453,6 +454,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
}, [loadCredentials]);
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(() => {
if (!remoteExec) return credentials;
const target = execContext === "winrm" ? "winrm" : "ssh";
@@ -463,6 +474,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
if (!remoteExec) {
return;
}
if (execContext === "winrm" && useSvcAccount) {
setSelectedCredentialId("");
return;
}
if (!filteredCredentials.length) {
setSelectedCredentialId("");
return;
@@ -470,7 +485,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(filteredCredentials[0].id));
}
}, [remoteExec, filteredCredentials, selectedCredentialId]);
}, [remoteExec, filteredCredentials, selectedCredentialId, execContext, useSvcAccount]);
// dialogs state
const [addCompOpen, setAddCompOpen] = useState(false);
@@ -877,12 +892,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const isValid = useMemo(() => {
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
if (!base) return false;
if (remoteExec && !selectedCredentialId) return false;
const needsCredential = remoteExec && !(execContext === "winrm" && useSvcAccount);
if (needsCredential && !selectedCredentialId) return false;
if (scheduleType !== "immediately") {
return !!startDateTime;
}
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 editing = !!(initialJob && initialJob.id);
@@ -1358,6 +1374,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setExpiration(initialJob.expiration || "no_expire");
setExecContext(initialJob.execution_context || "system");
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 hydrated = await hydrateExistingComponents(comps);
if (!canceled) {
@@ -1369,6 +1390,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setComponents([]);
setComponentVarErrors({});
setSelectedCredentialId("");
setUseSvcAccount(true);
}
};
hydrate();
@@ -1464,7 +1486,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
};
const handleCreate = async () => {
if (remoteExec && !selectedCredentialId) {
if (remoteExec && !(execContext === "winrm" && useSvcAccount) && !selectedCredentialId) {
alert("Please select a credential for this execution context.");
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 },
duration: { stopAfterEnabled, expiration },
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 {
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
size="small"
value={execContext}
onChange={(e) => setExecContext(e.target.value)}
onChange={(e) => handleExecContextChange(e.target.value)}
sx={{ minWidth: 320 }}
>
<MenuItem value="system">Run on agent as SYSTEM (device-local)</MenuItem>
@@ -1736,10 +1759,29 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Select>
{remoteExec && (
<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
size="small"
sx={{ minWidth: 320 }}
disabled={credentialLoading || !filteredCredentials.length}
disabled={credentialLoading || !filteredCredentials.length || (execContext === "winrm" && useSvcAccount)}
>
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select
@@ -1771,7 +1813,12 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
{credentialError}
</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" }}>
No {execContext === "winrm" ? "WinRM" : "SSH"} credentials available. Create one under Access Management &gt; Credentials.
</Typography>

View File

@@ -91,6 +91,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const [credentialsLoading, setCredentialsLoading] = useState(false);
const [credentialsError, setCredentialsError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [variables, setVariables] = useState([]);
const [variableValues, setVariableValues] = useState({});
const [variableErrors, setVariableErrors] = useState({});
@@ -120,6 +121,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
setUseSvcAccount(true);
setSelectedCredentialId("");
loadTree();
}
}, [open, loadTree]);
@@ -136,7 +139,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const data = await resp.json();
if (canceled) return;
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 || "")));
setCredentials(list);
@@ -161,7 +167,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
}, [open]);
useEffect(() => {
if (mode !== "ansible") return;
if (mode !== "ansible" || useSvcAccount) return;
if (!credentials.length) {
setSelectedCredentialId("");
return;
@@ -169,7 +175,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(credentials[0].id));
}
}, [mode, credentials, selectedCredentialId]);
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
const renderNodes = (nodes = []) =>
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.");
return;
}
if (mode === 'ansible' && !selectedCredentialId) {
if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) {
setError("Select a credential to run this playbook.");
return;
}
@@ -378,7 +384,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
playbook_path,
hostnames,
variable_values: variableOverrides,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : null
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: Boolean(useSvcAccount)
})
});
} else {
@@ -405,8 +412,11 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
}
};
const credentialRequired = mode === "ansible";
const disableRun = running || !selectedPath || (credentialRequired && (!selectedCredentialId || !credentials.length));
const credentialRequired = mode === "ansible" && !useSvcAccount;
const disableRun =
running ||
!selectedPath ||
(credentialRequired && (!selectedCredentialId || !credentials.length));
return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
@@ -423,10 +433,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
</Typography>
{mode === 'ansible' && (
<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
size="small"
sx={{ minWidth: 260 }}
disabled={credentialsLoading || !credentials.length}
disabled={useSvcAccount || credentialsLoading || !credentials.length}
>
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select
@@ -435,20 +464,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
onChange={(e) => setSelectedCredentialId(e.target.value)}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{credentials.map((cred) => (
<MenuItem key={cred.id} value={String(cred.id)}>
{cred.name}
</MenuItem>
))}
{credentials.map((cred) => {
const conn = String(cred.connection_type || "").toUpperCase();
return (
<MenuItem key={cred.id} value={String(cred.id)}>
{cred.name}
{conn ? ` (${conn})` : ""}
</MenuItem>
);
})}
</Select>
</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 && credentialsError && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
)}
{!credentialsLoading && !credentialsError && !credentials.length && (
{!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
<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>
)}
</Box>

View File

@@ -7,6 +7,10 @@ import re
import sqlite3
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
@@ -54,6 +58,26 @@ def _decode_base64_text(value: Any) -> Optional[str]:
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:
encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str):
@@ -311,6 +335,8 @@ class JobScheduler:
self._online_lookup: Optional[Callable[[], List[str]]] = None
# Optional callback to execute Ansible directly from the server
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
self._init_tables()
@@ -485,6 +511,7 @@ class JobScheduler:
scheduled_run_row_id: int,
run_mode: str,
credential_id: Optional[int] = None,
use_service_account: bool = False,
) -> Optional[Dict[str, Any]]:
try:
import os, uuid
@@ -522,7 +549,24 @@ class JobScheduler:
variables = doc.get("variables") or []
files = doc.get("files") or []
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
now = _now_ts()
@@ -743,6 +787,9 @@ class JobScheduler:
def _conn(self):
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):
conn = self._conn()
cur = conn.cursor()
@@ -954,7 +1001,7 @@ class JobScheduler:
pass
try:
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()
except Exception:
@@ -972,7 +1019,18 @@ class JobScheduler:
five_min = 300
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:
# Targets list for this job
try:
@@ -1008,6 +1066,9 @@ class JobScheduler:
continue
run_mode = (execution_context or "system").strip().lower()
job_credential_id = None
job_use_service_account = bool(use_service_account_flag)
if run_mode != "winrm":
job_use_service_account = False
try:
job_credential_id = int(credential_id) if credential_id is not None else None
except Exception:
@@ -1098,7 +1159,7 @@ class JobScheduler:
run_row_id = c2.lastrowid or 0
conn2.commit()
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:
err_msg = "Credential required for remote execution"
c2.execute(
@@ -1132,6 +1193,7 @@ class JobScheduler:
run_row_id,
run_mode,
job_credential_id,
job_use_service_account,
)
if link and link.get("activity_id"):
activity_links.append({
@@ -1243,9 +1305,10 @@ class JobScheduler:
"expiration": r[7] or "no_expire",
"execution_context": r[8] or "system",
"credential_id": r[9],
"enabled": bool(r[10] or 0),
"created_at": r[11] or 0,
"updated_at": r[12] or 0,
"use_service_account": bool(r[10] or 0),
"enabled": bool(r[11] or 0),
"created_at": r[12] or 0,
"updated_at": r[13] or 0,
}
# Attach computed status summary for latest occurrence
try:
@@ -1322,7 +1385,8 @@ class JobScheduler:
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
duration_stop_enabled, expiration, execution_context, credential_id,
use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs
ORDER BY created_at DESC
"""
@@ -1350,6 +1414,8 @@ class JobScheduler:
credential_id = int(credential_id) if credential_id is not None else None
except Exception:
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)))
if not name or not components or not targets:
return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"}
@@ -1360,8 +1426,8 @@ class JobScheduler:
cur.execute(
"""
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
(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 (?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
name,
@@ -1373,6 +1439,7 @@ class JobScheduler:
expiration,
execution_context,
credential_id,
use_service_account,
enabled,
now,
now,
@@ -1383,7 +1450,7 @@ class JobScheduler:
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
duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=?
""",
(job_id,),
@@ -1402,7 +1469,7 @@ class JobScheduler:
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
duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=?
""",
(job_id,),
@@ -1435,7 +1502,10 @@ class JobScheduler:
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"
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:
cred_val = data.get("credential_id")
if cred_val in (None, "", "null"):
@@ -1445,6 +1515,8 @@ class JobScheduler:
fields["credential_id"] = int(cred_val)
except Exception:
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:
fields["enabled"] = int(bool(data.get("enabled")))
if not fields:
@@ -1462,7 +1534,7 @@ class JobScheduler:
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
duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at
FROM scheduled_jobs WHERE id=?
""",
(job_id,),
@@ -1486,7 +1558,7 @@ class JobScheduler:
return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"}
conn.commit()
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,),
)
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]):
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 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_credential_fetcher as scheduler_set_credential_fetcher
# =============================================================================
# Section: Runtime Stack Configuration
@@ -1859,6 +1860,11 @@ def _ensure_ansible_workspace() -> str:
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]]:
try:
conn = _db_conn()
@@ -1876,7 +1882,8 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any
private_key_passphrase_encrypted,
become_method,
become_username,
become_password_encrypted
become_password_encrypted,
metadata_json
FROM credentials
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_username": row[9] or "",
"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):
if not row:
@@ -3514,6 +3553,7 @@ _DEVICE_TABLE_COLUMNS = [
"operating_system",
"uptime",
"agent_id",
"ansible_ee_ver",
"connection_type",
"connection_endpoint",
]
@@ -3603,6 +3643,7 @@ def _assemble_device_snapshot(record: Dict[str, Any]) -> Dict[str, Any]:
"created": _ts_to_human(created_ts),
"connection_type": _clean_device_str(record.get("connection_type")) or "",
"connection_endpoint": _clean_device_str(record.get("connection_endpoint")) or "",
"ansible_ee_ver": _clean_device_str(record.get("ansible_ee_ver")) or "",
}
details = {
@@ -3747,6 +3788,7 @@ def _extract_device_columns(details: Dict[str, Any]) -> Dict[str, Any]:
)
payload["uptime"] = _coerce_int(uptime_value)
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(
summary.get("connection_type")
or summary.get("remote_type")
@@ -3815,9 +3857,10 @@ def _device_upsert(
operating_system,
uptime,
agent_id,
ansible_ee_ver,
connection_type,
connection_endpoint
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description,
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),
uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime),
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_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("uptime"),
column_values.get("agent_id"),
column_values.get("ansible_ee_ver"),
column_values.get("connection_type"),
column_values.get("connection_endpoint"),
]
@@ -4160,7 +4205,10 @@ def init_db():
last_user TEXT,
operating_system TEXT,
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("uptime", "INTEGER")
_ensure_column("agent_id", "TEXT")
_ensure_column("ansible_ee_ver", "TEXT")
_ensure_column("connection_type", "TEXT")
_ensure_column("connection_endpoint", "TEXT")
@@ -4274,6 +4323,7 @@ def init_db():
operating_system TEXT,
uptime INTEGER,
agent_id TEXT,
ansible_ee_ver TEXT,
connection_type TEXT,
connection_endpoint TEXT
)
@@ -4481,6 +4531,7 @@ def init_db():
expiration TEXT,
execution_context TEXT NOT NULL,
credential_id INTEGER,
use_service_account INTEGER NOT NULL DEFAULT 1,
enabled INTEGER DEFAULT 1,
created_at INTEGER,
updated_at INTEGER
@@ -4492,6 +4543,8 @@ def init_db():
sj_cols = [row[1] for row in cur.fetchall()]
if "credential_id" not in sj_cols:
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:
pass
conn.commit()
@@ -4553,6 +4606,7 @@ ensure_default_admin()
job_scheduler = register_job_scheduler(app, socketio, DB_PATH)
scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run)
scheduler_set_credential_fetcher(job_scheduler, _fetch_credential_with_secrets)
job_scheduler.start()
# 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()
hostnames = data.get("hostnames") or []
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:
_ansible_log_server(f"[quick_run] invalid payload rel_path='{rel_path}' hostnames={hostnames}")
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
server_mode = False
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"):
try:
cred_id_int = int(credential_id)
if cred_id_int <= 0:
cred_id_int = None
else:
server_mode = True
except Exception:
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:
root, abs_path, _ = _resolve_assembly_path('ansible', rel_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 []
files = doc.get('files') if isinstance(doc.get('files'), list) else []
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:
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 = []
for host in hostnames:
# Create activity_history row so UI shows running state and can receive recap mirror
@@ -6499,6 +6560,9 @@ def ansible_quick_run():
except Exception:
pass
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})
except ValueError as ve:
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"
$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" {
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" {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue `
(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" {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $stagingPath
}