mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 22:01:59 -06:00
Fixed Major Agent Deployment Issues
This commit is contained in:
44
Borealis.ps1
44
Borealis.ps1
@@ -430,15 +430,17 @@ function InstallOrUpdate-BorealisAgent {
|
|||||||
Remove-BorealisServicesAndTasks -LogName 'Install.log'
|
Remove-BorealisServicesAndTasks -LogName 'Install.log'
|
||||||
Write-Host "Deploying Borealis Agent..." -ForegroundColor Blue
|
Write-Host "Deploying Borealis Agent..." -ForegroundColor Blue
|
||||||
|
|
||||||
$venvFolder = "Agent"
|
# Resolve all paths relative to the script directory to avoid CWD issues
|
||||||
$agentSourcePath = "Data\Agent\agent.py"
|
$venvFolderPath = Join-Path $scriptDir 'Agent'
|
||||||
$agentRequirements = "Data\Agent\agent-requirements.txt"
|
$agentSourceRoot = Join-Path $scriptDir 'Data\Agent'
|
||||||
$agentDestinationFolder = "$venvFolder\Borealis"
|
$agentSourcePath = Join-Path $agentSourceRoot 'agent.py'
|
||||||
$agentDestinationFile = "$venvFolder\Borealis\agent.py"
|
$agentRequirements = Join-Path $agentSourceRoot 'agent-requirements.txt'
|
||||||
$venvPython = Join-Path $scriptDir $venvFolder | Join-Path -ChildPath 'Scripts\python.exe'
|
$agentDestinationFolder = Join-Path $venvFolderPath 'Borealis'
|
||||||
|
$agentDestinationFile = Join-Path $agentDestinationFolder 'agent.py'
|
||||||
|
$venvPython = Join-Path $venvFolderPath 'Scripts\python.exe'
|
||||||
|
|
||||||
Run-Step "Create Virtual Python Environment" {
|
Run-Step "Create Virtual Python Environment" {
|
||||||
if (-not (Test-Path "$venvFolder\Scripts\Activate")) {
|
if (-not (Test-Path (Join-Path $venvFolderPath 'Scripts\Activate'))) {
|
||||||
$pythonForVenv = $pythonExe
|
$pythonForVenv = $pythonExe
|
||||||
if (-not (Test-Path $pythonForVenv)) {
|
if (-not (Test-Path $pythonForVenv)) {
|
||||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
@@ -450,23 +452,27 @@ function InstallOrUpdate-BorealisAgent {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& $pythonForVenv -m venv $venvFolder
|
& $pythonForVenv -m venv $venvFolderPath
|
||||||
}
|
}
|
||||||
if (Test-Path $agentSourcePath) {
|
if (Test-Path $agentSourcePath) {
|
||||||
|
# Cleanup Previous Agent Folder & Create New Folder
|
||||||
Remove-Item $agentDestinationFolder -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $agentDestinationFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
New-Item -Path $agentDestinationFolder -ItemType Directory -Force | Out-Null
|
New-Item -Path $agentDestinationFolder -ItemType Directory -Force | Out-Null
|
||||||
Copy-Item "Data\Agent\agent.py" $agentDestinationFolder -Recurse
|
|
||||||
# agent_info has been migrated into roles; no longer copied
|
# Copy Agent Files to Virtual Python Environment
|
||||||
# Legacy agent_roles kept for compatibility only if needed
|
$coreAgentFiles = @(
|
||||||
Copy-Item "Data\Agent\Python_API_Endpoints" $agentDestinationFolder -Recurse
|
(Join-Path $agentSourceRoot 'agent.py'),
|
||||||
Copy-Item "Data\Agent\role_manager.py" $agentDestinationFolder -Force
|
(Join-Path $agentSourceRoot 'Python_API_Endpoints'),
|
||||||
if (Test-Path "Data\Agent\Roles") { Copy-Item "Data\Agent\Roles" $agentDestinationFolder -Recurse }
|
(Join-Path $agentSourceRoot 'agent_deployment.py'),
|
||||||
Copy-Item "Data\Agent\agent_deployment.py" $agentDestinationFolder -Force
|
(Join-Path $agentSourceRoot 'Borealis.ico'),
|
||||||
# tray is now embedded in CURRENTUSER role; no launcher to copy
|
(Join-Path $agentSourceRoot 'launch_service.ps1'),
|
||||||
if (Test-Path "Data\Agent\Borealis.ico") { Copy-Item "Data\Agent\Borealis.ico" $agentDestinationFolder -Force }
|
(Join-Path $agentSourceRoot 'role_manager.py'),
|
||||||
if (Test-Path "Data\Agent\launch_service.ps1") { Copy-Item "Data\Agent\launch_service.ps1" $agentDestinationFolder -Force }
|
(Join-Path $agentSourceRoot 'Roles')
|
||||||
|
)
|
||||||
|
|
||||||
|
Copy-Item $coreAgentFiles -Destination $agentDestinationFolder -Recurse -Force
|
||||||
}
|
}
|
||||||
. "$venvFolder\Scripts\Activate"
|
. (Join-Path $venvFolderPath 'Scripts\Activate')
|
||||||
}
|
}
|
||||||
|
|
||||||
Run-Step "Install Python Dependencies" {
|
Run-Step "Install Python Dependencies" {
|
||||||
|
|||||||
120
Data/Agent/Roles/Device_Audit.yml
Normal file
120
Data/Agent/Roles/Device_Audit.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
- hosts: all
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Collect summary via PowerShell
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$hostname = $env:COMPUTERNAME
|
||||||
|
$username = $env:USERNAME
|
||||||
|
$domain = $env:USERDOMAIN
|
||||||
|
$w = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
|
||||||
|
$product = [string]$w.ProductName
|
||||||
|
$display = [string]$w.DisplayVersion
|
||||||
|
$build = [string]$w.CurrentBuildNumber
|
||||||
|
$os = ($product + ' ' + $display).Trim()
|
||||||
|
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||||
|
$uptime = [int]((Get-Date) - $boot).TotalSeconds
|
||||||
|
$epoch = [int](((Get-Date $boot).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds)
|
||||||
|
$out = [pscustomobject]@{ hostname=$hostname; os=$os; username=$username; domain=$domain; uptime_sec=$uptime; last_reboot=$epoch }
|
||||||
|
$out | ConvertTo-Json -Depth 4
|
||||||
|
register: summary_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse summary JSON
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_summary: "{{ summary_raw.stdout | from_json | default({}, true) }}"
|
||||||
|
|
||||||
|
- name: Collect installed software
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$paths = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*')
|
||||||
|
$items = foreach ($p in $paths) {
|
||||||
|
if (Test-Path $p) {
|
||||||
|
Get-ItemProperty -Path $p | Where-Object { $_.DisplayName } | ForEach-Object {
|
||||||
|
[pscustomobject]@{ name=[string]$_.DisplayName; version=[string]$_.DisplayVersion }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$items | Sort-Object name -Unique | ConvertTo-Json -Depth 4
|
||||||
|
register: software_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse software JSON
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_software: "{{ software_raw.stdout | default('[]') | from_json | default([], true) }}"
|
||||||
|
|
||||||
|
- name: Collect memory modules
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
Get-CimInstance Win32_PhysicalMemory | ForEach-Object {
|
||||||
|
[pscustomobject]@{ slot=$_.BankLabel; speed=[string]$_.Speed; serial=[string]$_.SerialNumber; capacity=[string]$_.Capacity }
|
||||||
|
} | ConvertTo-Json -Depth 4
|
||||||
|
register: memory_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse memory JSON
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_memory: "{{ memory_raw.stdout | default('[]') | from_json | default([], true) }}"
|
||||||
|
|
||||||
|
- name: Collect storage volumes
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
Get-CimInstance Win32_LogicalDisk | ForEach-Object {
|
||||||
|
$total = [double]$_.Size; $free = [double]$_.FreeSpace; $used = $total - $free;
|
||||||
|
$usage = if ($total -gt 0) { [math]::Round(($used / $total) * 100, 2) } else { 0 }
|
||||||
|
$type = switch ($_.DriveType) { 2 {'Removable'} 3 {'Fixed Disk'} default {'Unknown'} }
|
||||||
|
[pscustomobject]@{ drive=$_.DeviceID; disk_type=$type; usage=$usage; total=$total; free=$free; used=$used }
|
||||||
|
} | ConvertTo-Json -Depth 4
|
||||||
|
register: storage_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse storage JSON
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_storage: "{{ storage_raw.stdout | default('[]') | from_json | default([], true) }}"
|
||||||
|
|
||||||
|
- name: Collect network adapters
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
try {
|
||||||
|
$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\.254\.' -and $_.IPAddress -ne '127.0.0.1' }
|
||||||
|
$ad = Get-NetAdapter | ForEach-Object { $_ | Select-Object -Property Name, InterfaceAlias, MacAddress }
|
||||||
|
$map = @{}; foreach($a in $ad){ $map[$a.InterfaceAlias] = $a.MacAddress }
|
||||||
|
$tmp = @{}
|
||||||
|
foreach($e in $ip){
|
||||||
|
$alias = if ($e.InterfaceAlias) { $e.InterfaceAlias } else { 'unknown' }
|
||||||
|
$item = $tmp[$alias]
|
||||||
|
if (-not $item) { $item = [pscustomobject]@{ adapter=$alias; ips=@(); mac='' }; $tmp[$alias] = $item }
|
||||||
|
$item.mac = $map[$alias]
|
||||||
|
if ($e.IPAddress -and $item.ips -notcontains $e.IPAddress) { $item.ips += $e.IPAddress }
|
||||||
|
}
|
||||||
|
$out = $tmp.GetEnumerator() | ForEach-Object { $_.Value }
|
||||||
|
} catch { $out = @() }
|
||||||
|
$out | ConvertTo-Json -Depth 4
|
||||||
|
register: network_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse network JSON
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_network: "{{ network_raw.stdout | default('[]') | from_json | default([], true) }}"
|
||||||
|
|
||||||
|
- name: Compose device details structure
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
device_details:
|
||||||
|
summary: "{{ _summary }}"
|
||||||
|
software: "{{ _software }}"
|
||||||
|
memory: "{{ _memory }}"
|
||||||
|
storage: "{{ _storage }}"
|
||||||
|
network: "{{ _network }}"
|
||||||
|
|
||||||
|
- name: Derive internal IP
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'internal_ip': ( (_network | map(attribute='ips') | list | first | default([])) | first | default('') ) })) }) }}"
|
||||||
|
|
||||||
|
- name: Write device details JSON
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ device_details | to_nice_json }}"
|
||||||
|
dest: "{{ output_file }}"
|
||||||
|
run_once: true
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
@@ -7,6 +8,7 @@ import subprocess
|
|||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psutil # type: ignore
|
import psutil # type: ignore
|
||||||
@@ -124,6 +126,79 @@ def collect_summary(CONFIG):
|
|||||||
return {'hostname': socket.gethostname()}
|
return {'hostname': socket.gethostname()}
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root():
|
||||||
|
try:
|
||||||
|
# Agent layout: Agent/Borealis/{this_file}; root is two levels up
|
||||||
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
except Exception:
|
||||||
|
return os.getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ansible_audit(ctx) -> dict:
|
||||||
|
try:
|
||||||
|
exe_dir = os.path.dirname(sys.executable)
|
||||||
|
candidate = os.path.join(exe_dir, 'ansible-playbook.exe' if IS_WINDOWS else 'ansible-playbook')
|
||||||
|
ansible_playbook = candidate if os.path.isfile(candidate) else 'ansible-playbook'
|
||||||
|
|
||||||
|
base = os.path.join(_project_root(), 'Logs', 'Agent', 'ansible')
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
out_path = os.path.join(base, 'audit.json')
|
||||||
|
|
||||||
|
# Require an external playbook; look next to this role first, then source tree as fallback
|
||||||
|
roles_dir = os.path.dirname(__file__)
|
||||||
|
pb_candidates = [
|
||||||
|
os.path.join(roles_dir, 'Device_Audit.yml'),
|
||||||
|
os.path.join(_project_root(), 'Data', 'Agent', 'Roles', 'Device_Audit.yml'),
|
||||||
|
]
|
||||||
|
pb_path = next((p for p in pb_candidates if os.path.isfile(p)), None)
|
||||||
|
if not pb_path:
|
||||||
|
# Log helpful error and return empty
|
||||||
|
try:
|
||||||
|
with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef:
|
||||||
|
ef.write('Device_Audit.yml not found in roles directory.\n')
|
||||||
|
ef.write('Searched:\n - ' + '\n - '.join(pb_candidates))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out_posix = Path(out_path).as_posix()
|
||||||
|
py_interp = Path(sys.executable).as_posix()
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||||
|
env.setdefault('ANSIBLE_FORCE_COLOR', '0')
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ansible_playbook,
|
||||||
|
'-i', 'localhost,',
|
||||||
|
'-c', 'local',
|
||||||
|
pb_path,
|
||||||
|
'-e', f'ansible_python_interpreter={py_interp}',
|
||||||
|
'-e', 'ansible_shell_type=powershell' if IS_WINDOWS else 'ansible_shell_type=sh',
|
||||||
|
'-e', 'ansible_shell_executable=powershell.exe' if IS_WINDOWS else 'ansible_shell_executable=/bin/sh',
|
||||||
|
'-e', f'output_file={out_posix}',
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef:
|
||||||
|
ef.write(proc.stdout or '')
|
||||||
|
ef.write('\n--- STDERR ---\n')
|
||||||
|
ef.write(proc.stderr or '')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(out_path, 'r', encoding='utf-8') as jf:
|
||||||
|
details = json.load(jf)
|
||||||
|
return details if isinstance(details, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _ps_json(cmd: str, timeout: int = 60):
|
def _ps_json(cmd: str, timeout: int = 60):
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", cmd], capture_output=True, text=True, timeout=timeout)
|
out = subprocess.run(["powershell", "-NoProfile", "-Command", cmd], capture_output=True, text=True, timeout=timeout)
|
||||||
@@ -425,6 +500,9 @@ class Role:
|
|||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self._ext_ip = None
|
self._ext_ip = None
|
||||||
self._ext_ip_ts = 0
|
self._ext_ip_ts = 0
|
||||||
|
self._ansible_cache = None
|
||||||
|
self._ansible_ts = 0
|
||||||
|
self._last_details = None
|
||||||
try:
|
try:
|
||||||
# Set OS string once
|
# Set OS string once
|
||||||
self.ctx.config.data['agent_operating_system'] = detect_agent_os()
|
self.ctx.config.data['agent_operating_system'] = detect_agent_os()
|
||||||
@@ -445,78 +523,35 @@ class Role:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def _report_loop(self):
|
async def _report_loop(self):
|
||||||
|
interval_sec = 300 # post heartbeat/details every 5 minutes
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
details = {
|
# Determine audit refresh interval (minutes), default 30
|
||||||
'summary': collect_summary(self.ctx.config),
|
|
||||||
'software': collect_software(),
|
|
||||||
'memory': collect_memory(),
|
|
||||||
'storage': collect_storage(),
|
|
||||||
'network': collect_network(),
|
|
||||||
}
|
|
||||||
# Derive additional summary fields
|
|
||||||
try:
|
try:
|
||||||
# Internal IP: first IPv4 on first adapter
|
refresh_min = int(self.ctx.config.data.get('audit_interval_minutes', 30))
|
||||||
internal_ip = ''
|
|
||||||
for a in (details.get('network') or []):
|
|
||||||
for ip in (a.get('ips') or []):
|
|
||||||
if ip and not ip.startswith('169.254.') and ip != '127.0.0.1':
|
|
||||||
internal_ip = ip
|
|
||||||
break
|
|
||||||
if internal_ip:
|
|
||||||
break
|
|
||||||
details['summary']['internal_ip'] = internal_ip
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
refresh_min = 30
|
||||||
try:
|
refresh_sec = max(300, refresh_min * 60)
|
||||||
details['summary']['device_type'] = detect_device_type()
|
|
||||||
except Exception:
|
now = time.time()
|
||||||
pass
|
need_refresh = (not self._last_details) or ((now - self._ansible_ts) > refresh_sec)
|
||||||
try:
|
if need_refresh:
|
||||||
if psutil:
|
details = _run_ansible_audit(self.ctx)
|
||||||
details['summary']['last_reboot'] = int(psutil.boot_time())
|
if details:
|
||||||
except Exception:
|
self._last_details = details
|
||||||
pass
|
self._ansible_ts = now
|
||||||
|
|
||||||
|
# Always post the latest available details (possibly cached)
|
||||||
|
details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)}
|
||||||
url = (self.ctx.config.data.get('borealis_server_url', 'http://localhost:5000') or '').rstrip('/') + '/api/agent/details'
|
url = (self.ctx.config.data.get('borealis_server_url', 'http://localhost:5000') or '').rstrip('/') + '/api/agent/details'
|
||||||
payload = {
|
payload = {
|
||||||
'agent_id': self.ctx.agent_id,
|
'agent_id': self.ctx.agent_id,
|
||||||
'hostname': details.get('summary', {}).get('hostname', socket.gethostname()),
|
'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()),
|
||||||
'details': details,
|
'details': details_to_send,
|
||||||
}
|
}
|
||||||
if aiohttp is not None:
|
if aiohttp is not None:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
# External IP: refresh at most every 30 minutes
|
|
||||||
try:
|
|
||||||
now = time.time()
|
|
||||||
if (now - self._ext_ip_ts) > 1800:
|
|
||||||
# Try ipify JSON; fallback to plain-text ifconfig.me
|
|
||||||
ok = False
|
|
||||||
try:
|
|
||||||
async with session.get('https://api.ipify.org?format=json', timeout=8) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
j = await resp.json()
|
|
||||||
self._ext_ip = (j.get('ip') or '').strip()
|
|
||||||
self._ext_ip_ts = now
|
|
||||||
ok = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not ok:
|
|
||||||
try:
|
|
||||||
async with session.get('https://ifconfig.me/ip', timeout=8) as resp2:
|
|
||||||
if resp2.status == 200:
|
|
||||||
t = (await resp2.text()) or ''
|
|
||||||
t = t.strip()
|
|
||||||
if t:
|
|
||||||
self._ext_ip = t
|
|
||||||
self._ext_ip_ts = now
|
|
||||||
ok = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if self._ext_ip:
|
|
||||||
details['summary']['external_ip'] = self._ext_ip
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await session.post(url, json=payload, timeout=10)
|
await session.post(url, json=payload, timeout=10)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await asyncio.sleep(300)
|
await asyncio.sleep(interval_sec)
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ pywinauto # Windows-based Macro Automation Library
|
|||||||
sounddevice
|
sounddevice
|
||||||
numpy
|
numpy
|
||||||
pywin32; platform_system == "Windows"
|
pywin32; platform_system == "Windows"
|
||||||
|
|
||||||
|
# Ansible-based inventory collection (Windows local-only)
|
||||||
|
# Note: ansible-core is heavy; enable via config flag in DeviceAudit role
|
||||||
|
ansible-core
|
||||||
|
|||||||
Reference in New Issue
Block a user