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 }