From 1e9912efd21d5f549f7e5b02462faa6b946ebc9f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 12 Oct 2025 14:13:06 -0600 Subject: [PATCH 01/10] Provision reusable Ansible execution environment --- Borealis.ps1 | 155 +++++++++++++++++ Data/Agent/Roles/role_DeviceAudit.py | 41 ++++- Data/Agent/Roles/role_PlaybookExec_SYSTEM.py | 166 ++++++++++++++----- Data/Agent/ansible-ee-requirements.txt | 8 + Data/Agent/ansible-ee-version.txt | 2 + Data/Server/server.py | 15 +- Update.ps1 | 21 +++ 7 files changed, 367 insertions(+), 41 deletions(-) create mode 100644 Data/Agent/ansible-ee-requirements.txt create mode 100644 Data/Agent/ansible-ee-version.txt diff --git a/Borealis.ps1 b/Borealis.ps1 index e1fde6a..b4527d8 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -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,139 @@ function Install_Agent_Dependencies { } } +function Ensure-AnsibleExecutionEnvironment { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectRoot, + + [Parameter(Mandatory = $true)] + [string]$PythonBootstrapExe, + + [string]$RequirementsPath, + [string]$ExpectedVersion = '1.0.0', + [string]$LogName = 'Install.log' + ) + + if (-not (Test-Path $PythonBootstrapExe -PathType Leaf)) { + Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Bundled python executable missing at $PythonBootstrapExe" + throw "Bundled python executable not found for Ansible execution environment provisioning." + } + + $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 ?? '1.0.0').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 + + & $PythonBootstrapExe -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 + } + if ($requirementsHash) { + $metadata['requirements_hash'] = $requirementsHash + } + + try { + $metadata | ConvertTo-Json -Depth 5 | Set-Content -Path $metadataPath -Encoding UTF8 + } 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 UTF8 + } 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 +715,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' diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index d78b3fd..c5e646d 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -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(): diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index 2c05cca..f27ec29 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -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,11 +156,22 @@ 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 @@ -137,10 +205,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: @@ -185,40 +262,55 @@ class Role: 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}") return True async def _ensure_ansible_ready(self) -> bool: diff --git a/Data/Agent/ansible-ee-requirements.txt b/Data/Agent/ansible-ee-requirements.txt new file mode 100644 index 0000000..8bebb6b --- /dev/null +++ b/Data/Agent/ansible-ee-requirements.txt @@ -0,0 +1,8 @@ +#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 diff --git a/Data/Agent/ansible-ee-version.txt b/Data/Agent/ansible-ee-version.txt new file mode 100644 index 0000000..9980c20 --- /dev/null +++ b/Data/Agent/ansible-ee-version.txt @@ -0,0 +1,2 @@ +#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/Agent/ansible-ee-version.txt +1.0.0 diff --git a/Data/Server/server.py b/Data/Server/server.py index 8b6d2ad..b111e9c 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -3514,6 +3514,7 @@ _DEVICE_TABLE_COLUMNS = [ "operating_system", "uptime", "agent_id", + "ansible_ee_ver", "connection_type", "connection_endpoint", ] @@ -3603,6 +3604,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 +3749,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 +3818,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 +3842,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 +3868,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 +4166,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 +4202,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 +4284,7 @@ def init_db(): operating_system TEXT, uptime INTEGER, agent_id TEXT, + ansible_ee_ver TEXT, connection_type TEXT, connection_endpoint TEXT ) diff --git a/Update.ps1 b/Update.ps1 index 212ac56..fdd631c 100644 --- a/Update.ps1 +++ b/Update.ps1 @@ -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 } From 0eb20d415cd75770d5e2aed428de0d66ed55c161 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 12 Oct 2025 16:04:45 -0600 Subject: [PATCH 02/10] Fix PowerShell null-coalescing usage for Ansible EE --- Borealis.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index b4527d8..3cd2d06 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -524,7 +524,11 @@ function Ensure-AnsibleExecutionEnvironment { $existingPython = $pythonCandidates | Where-Object { Test-Path $_ -PathType Leaf } | Select-Object -First 1 - $expectedVersionNorm = ($ExpectedVersion ?? '1.0.0').Trim() + $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)) { From e5ad7ae9b1be11fe9bd6836be259bc05938426c9 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 12 Oct 2025 16:54:55 -0600 Subject: [PATCH 03/10] Default Ansible EE provisioning to bundled Python --- Borealis.ps1 | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index 3cd2d06..4e5a079 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -467,7 +467,6 @@ function Ensure-AnsibleExecutionEnvironment { [Parameter(Mandatory = $true)] [string]$ProjectRoot, - [Parameter(Mandatory = $true)] [string]$PythonBootstrapExe, [string]$RequirementsPath, @@ -475,11 +474,35 @@ function Ensure-AnsibleExecutionEnvironment { [string]$LogName = 'Install.log' ) - if (-not (Test-Path $PythonBootstrapExe -PathType Leaf)) { - Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Bundled python executable missing at $PythonBootstrapExe" - throw "Bundled python executable not found for Ansible execution environment provisioning." + $pythonBootstrap = $PythonBootstrapExe + if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { + $bundleCandidate = Join-Path $ProjectRoot 'Dependencies\Python\python.exe' + if (Test-Path $bundleCandidate -PathType Leaf) { + $pythonBootstrap = $bundleCandidate + } } + if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { + $pyCmd = Get-Command py -ErrorAction SilentlyContinue + if ($pyCmd -and (Test-Path $pyCmd.Source -PathType Leaf)) { + $pythonBootstrap = $pyCmd.Source + } + } + + if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { + $pythonCmd = Get-Command python -ErrorAction SilentlyContinue + if ($pythonCmd -and (Test-Path $pythonCmd.Source -PathType Leaf)) { + $pythonBootstrap = $pythonCmd.Source + } + } + + if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { + Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Unable to locate Python bootstrap executable." + throw "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' @@ -548,7 +571,7 @@ function Ensure-AnsibleExecutionEnvironment { } New-Item -ItemType Directory -Force -Path $eeRoot | Out-Null - & $PythonBootstrapExe -m venv $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." @@ -580,6 +603,7 @@ function Ensure-AnsibleExecutionEnvironment { version = $expectedVersionNorm created_utc = (Get-Date).ToUniversalTime().ToString('o') python = $pythonExe + bootstrap_python = $pythonBootstrap } if ($requirementsHash) { $metadata['requirements_hash'] = $requirementsHash From 1cae00fb1ffde67c0226671fd13b68e7cdd72944 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 12 Oct 2025 16:55:01 -0600 Subject: [PATCH 04/10] Pin Ansible EE bootstrap to bundled Python --- Borealis.ps1 | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index 4e5a079..e90162c 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -475,32 +475,24 @@ function Ensure-AnsibleExecutionEnvironment { ) $pythonBootstrap = $PythonBootstrapExe - if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { - $bundleCandidate = Join-Path $ProjectRoot 'Dependencies\Python\python.exe' + $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." } } - if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { - $pyCmd = Get-Command py -ErrorAction SilentlyContinue - if ($pyCmd -and (Test-Path $pyCmd.Source -PathType Leaf)) { - $pythonBootstrap = $pyCmd.Source - } - } - - if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { - $pythonCmd = Get-Command python -ErrorAction SilentlyContinue - if ($pythonCmd -and (Test-Path $pythonCmd.Source -PathType Leaf)) { - $pythonBootstrap = $pythonCmd.Source - } - } - - if ([string]::IsNullOrWhiteSpace($pythonBootstrap) -or -not (Test-Path $pythonBootstrap -PathType Leaf)) { - Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Unable to locate Python bootstrap executable." - throw "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' From 01189b4b341c99cccff1da912401566977b6ec86 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Tue, 14 Oct 2025 21:51:42 -0600 Subject: [PATCH 05/10] Fix PowerShell line continuation for EE provisioning --- Borealis.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index e90162c..a5f14af 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -736,11 +736,11 @@ function InstallOrUpdate-BorealisAgent { } Run-Step "Provision Ansible Execution Environment" { - Ensure-AnsibleExecutionEnvironment \ - -ProjectRoot $scriptDir \ - -PythonBootstrapExe $pythonExe \ - -RequirementsPath $ansibleEeRequirementsPath \ - -ExpectedVersion $script:AnsibleExecutionEnvironmentVersion \ + Ensure-AnsibleExecutionEnvironment ` + -ProjectRoot $scriptDir ` + -PythonBootstrapExe $pythonExe ` + -RequirementsPath $ansibleEeRequirementsPath ` + -ExpectedVersion $script:AnsibleExecutionEnvironmentVersion ` -LogName 'Install.log' } From 94250212de5d3e36441076457ffd23dc9a4895ff Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 00:32:47 -0600 Subject: [PATCH 06/10] Fix EE python path candidate list --- Borealis.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index a5f14af..c5f37b9 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -531,10 +531,10 @@ function Ensure-AnsibleExecutionEnvironment { } $pythonCandidates = @( - Join-Path $eeRoot 'Scripts\python.exe', - Join-Path $eeRoot 'Scripts\python3.exe', - Join-Path $eeRoot 'bin\python3', - Join-Path $eeRoot 'bin\python' + (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 From 74540b7f10a7495fcf9c46bcb660b73618f9479c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 01:20:54 -0600 Subject: [PATCH 07/10] Pass stored WinRM credentials to agent Ansible runs --- .../Server/WebUI/src/Scheduling/Quick_Job.jsx | 21 ++++-- Data/Server/job_scheduler.py | 52 +++++++++++++- Data/Server/server.py | 70 +++++++++++++++---- 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx index d83ac31..35359c9 100644 --- a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx @@ -136,7 +136,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); @@ -435,11 +438,15 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { onChange={(e) => setSelectedCredentialId(e.target.value)} sx={{ bgcolor: "#1f1f1f", color: "#fff" }} > - {credentials.map((cred) => ( - - {cred.name} - - ))} + {credentials.map((cred) => { + const conn = String(cred.connection_type || "").toUpperCase(); + return ( + + {cred.name} + {conn ? ` (${conn})` : ""} + + ); + })} {credentialsLoading && } @@ -448,7 +455,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { )} {!credentialsLoading && !credentialsError && !credentials.length && ( - No SSH credentials available. Create one under Access Management. + No SSH or WinRM credentials available. Create one under Access Management. )} diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index bf95d77..e0ae58a 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -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() @@ -522,7 +548,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: + 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 +786,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() @@ -1738,3 +1784,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 diff --git a/Data/Server/server.py b/Data/Server/server.py index b111e9c..0893f99 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -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: @@ -4564,6 +4603,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 @@ -6375,15 +6415,26 @@ def ansible_quick_run(): return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400 server_mode = False cred_id_int = None + credential_detail: Optional[Dict[str, Any]] = None 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 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): @@ -6407,16 +6458,6 @@ def ansible_quick_run(): 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 @@ -6510,6 +6551,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 From 2f8ff949fc052442a8a6bd587aa8f249580c4d4f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 02:58:55 -0600 Subject: [PATCH 08/10] Allow selecting svcBorealis account for playbooks --- .../WebUI/src/Scheduling/Create_Job.jsx | 63 ++++++++++++++++--- .../Server/WebUI/src/Scheduling/Quick_Job.jsx | 47 +++++++++++--- Data/Server/job_scheduler.py | 56 ++++++++++++----- Data/Server/server.py | 29 ++++++--- 4 files changed, 154 insertions(+), 41 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx index e281867..89b0996 100644 --- a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -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 }) { {remoteExec && ( + {execContext === "winrm" && ( + { + 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" + /> + )} Credential + {useSvcAccount && ( + + Runs with the agent's svcBorealis account. + + )} {credentialsLoading && } {!credentialsLoading && credentialsError && ( {credentialsError} )} - {!credentialsLoading && !credentialsError && !credentials.length && ( + {!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && ( No SSH or WinRM credentials available. Create one under Access Management. diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index e0ae58a..eb72788 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -511,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 @@ -551,7 +552,7 @@ class JobScheduler: server_run = run_mode_norm == "ssh" agent_winrm = run_mode_norm == "winrm" - if agent_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): @@ -1000,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: @@ -1018,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: @@ -1054,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: @@ -1144,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( @@ -1178,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({ @@ -1289,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: @@ -1368,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 """ @@ -1396,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"} @@ -1406,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, @@ -1419,6 +1439,7 @@ class JobScheduler: expiration, execution_context, credential_id, + use_service_account, enabled, now, now, @@ -1429,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,), @@ -1448,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,), @@ -1481,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"): @@ -1491,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: @@ -1508,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,), @@ -1532,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() diff --git a/Data/Server/server.py b/Data/Server/server.py index 0893f99..0c117f6 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -4531,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 @@ -4542,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() @@ -6410,12 +6413,21 @@ 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) @@ -6423,7 +6435,13 @@ def ansible_quick_run(): cred_id_int = None 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: @@ -6446,15 +6464,6 @@ 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 From 352c71daa9360ddb0482cecb8473cd0989169fef Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 03:24:35 -0600 Subject: [PATCH 09/10] Use EE python to validate Ansible deps --- Data/Agent/Roles/role_PlaybookExec_SYSTEM.py | 40 +++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index f27ec29..2238398 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -252,12 +252,42 @@ 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: From 1af14d907a0026e67d630242ac5ebcafd7663b8c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 03:34:02 -0600 Subject: [PATCH 10/10] Stub fcntl in Ansible EE and expose support path --- Borealis.ps1 | 56 +++++++++++++++++++- Data/Agent/Roles/role_PlaybookExec_SYSTEM.py | 23 ++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index c5f37b9..810e9a7 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -601,15 +601,67 @@ function Ensure-AnsibleExecutionEnvironment { $metadata['requirements_hash'] = $requirementsHash } + $supportDir = Join-Path $eeRoot 'support' try { - $metadata | ConvertTo-Json -Depth 5 | Set-Content -Path $metadataPath -Encoding UTF8 + 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 UTF8 + Set-Content -Path $versionTxtPath -Value $expectedVersionNorm -Encoding UTF8NoBOM } catch {} Write-AgentLog -FileName $LogName -Message "[AnsibleEE] Execution environment ready at $eeRoot" diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index 2238398..62a7fb4 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -178,6 +178,16 @@ def _scripts_bin(): 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() @@ -341,6 +351,19 @@ class Role: 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: