Add silent update trigger for agents

This commit is contained in:
2025-10-04 14:44:35 -06:00
parent db5975c136
commit f0ab7773f8
3 changed files with 257 additions and 3 deletions

View File

@@ -17,6 +17,76 @@ def _project_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
def _find_borealis_root() -> Optional[str]:
override = os.environ.get('BOREALIS_ROOT') or os.environ.get('BOREALIS_PROJECT_ROOT')
if override:
candidate = os.path.abspath(override)
if os.path.isfile(os.path.join(candidate, 'Borealis.ps1')):
return candidate
cur = _project_root()
for _ in range(8):
if os.path.isfile(os.path.join(cur, 'Borealis.ps1')):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
fallback = os.path.abspath(os.path.join(_project_root(), '..'))
if os.path.isfile(os.path.join(fallback, 'Borealis.ps1')):
return fallback
return None
def _launch_silent_update_task():
if os.name != 'nt':
raise RuntimeError('Silent update is supported on Windows hosts only.')
root = _find_borealis_root()
if not root:
raise RuntimeError('Unable to locate Borealis.ps1 on this agent.')
ps_exe = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
if not os.path.isfile(ps_exe):
ps_exe = 'powershell.exe'
task_name = f"Borealis Agent - SilentUpdate - {uuid.uuid4().hex}"
task_literal = _ps_literal(task_name)
ps_literal = _ps_literal(ps_exe)
root_literal = _ps_literal(root)
args_literal = _ps_literal('-NoProfile -ExecutionPolicy Bypass -File .\\Borealis.ps1 -SilentUpdate')
cleanup_script = (
"Start-Job -ScriptBlock { Start-Sleep -Seconds 600; "
f"try {{ Unregister-ScheduledTask -TaskName {task_literal} -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}} }} | Out-Null"
)
ps_script = "\n".join(
[
"$ErrorActionPreference='Stop'",
f"$task = {task_literal}",
"try { Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue } catch {}",
f"$action = New-ScheduledTaskAction -Execute {ps_literal} -Argument {args_literal} -WorkingDirectory {root_literal}",
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 30)",
"$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest",
"Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null",
"Start-ScheduledTask -TaskName $task | Out-Null",
cleanup_script,
]
)
flags = 0x08000000 if os.name == 'nt' else 0
proc = subprocess.run(
[ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps_script],
capture_output=True,
text=True,
creationflags=flags,
)
if proc.returncode != 0:
stderr = proc.stderr or proc.stdout or 'scheduled task registration failed'
raise RuntimeError(stderr.strip())
def _canonical_env_key(name: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
return cleaned.upper()
@@ -258,6 +328,46 @@ class Role:
def register_events(self):
sio = self.ctx.sio
@sio.on('agent_silent_update')
async def _on_agent_silent_update(payload):
hostname = None
details = payload if isinstance(payload, dict) else {}
try:
import socket
hostname = socket.gethostname()
target = (details.get('target_hostname') or '').strip().lower()
if target and target != hostname.lower():
return
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _launch_silent_update_task)
try:
await sio.emit(
'agent_silent_update_status',
{
'request_id': details.get('request_id') or '',
'hostname': hostname,
'status': 'started',
},
)
except Exception:
pass
except Exception as e:
try:
await sio.emit(
'agent_silent_update_status',
{
'request_id': details.get('request_id') or '',
'hostname': hostname or '',
'status': 'error',
'error': str(e),
},
)
except Exception:
pass
@sio.on('quick_job_run')
async def _on_quick_job_run(payload):
try:

View File

@@ -18,7 +18,9 @@ import {
MenuItem,
Popover,
TextField,
Tooltip
Tooltip,
Snackbar,
Alert
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList";
@@ -61,6 +63,8 @@ export default function DeviceList({ onSelectDevice }) {
// Track selection by agent id to avoid duplicate hostname collisions
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [quickJobOpen, setQuickJobOpen] = useState(false);
const [updateLoading, setUpdateLoading] = useState(false);
const [updateStatus, setUpdateStatus] = useState({ open: false, severity: "success", message: "" });
// Saved custom views (from server)
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
@@ -422,6 +426,49 @@ export default function DeviceList({ onSelectDevice }) {
});
};
const triggerSilentUpdate = useCallback(async () => {
const hostnames = Array.from(
new Set(
rows
.filter((r) => selectedIds.has(r.id))
.map((r) => r.hostname)
.filter(Boolean)
)
);
if (!hostnames.length) return;
setUpdateLoading(true);
try {
const resp = await fetch('/api/agents/silent_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostnames }),
});
let payload = null;
try {
payload = await resp.json();
} catch (err) {
payload = null;
}
if (!resp.ok || (payload && payload.error)) {
const msg = payload && payload.error ? payload.error : `HTTP ${resp.status}`;
throw new Error(msg);
}
const count = Array.isArray(payload?.results) ? payload.results.length : hostnames.length;
const requestId = typeof payload?.request_id === 'string' && payload.request_id ? payload.request_id : '';
setUpdateStatus({
open: true,
severity: 'success',
message: `Silent update triggered for ${count} device${count === 1 ? '' : 's'}${requestId ? ` (request ${requestId})` : ''}.`,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setUpdateStatus({ open: true, severity: 'error', message: `Failed to trigger silent update: ${message}` });
} finally {
setUpdateLoading(false);
}
}, [rows, selectedIds]);
// Column drag handlers
const onHeaderDragStart = (colId) => (e) => {
dragColId.current = colId;
@@ -572,7 +619,23 @@ export default function DeviceList({ onSelectDevice }) {
</Box>
</Box>
{/* Second row: Quick Job button aligned under header title */}
<Box sx={{ display: 'flex' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
size="small"
disabled={selectedIds.size === 0 || updateLoading}
onClick={triggerSilentUpdate}
sx={{
textTransform: "none",
bgcolor: selectedIds.size === 0 || updateLoading ? "#2d4a6a" : "#1f6feb",
color: "#fff",
'&:hover': {
bgcolor: selectedIds.size === 0 || updateLoading ? "#2d4a6a" : "#388bfd"
}
}}
>
{updateLoading ? "Updating..." : "Update Agent"}
</Button>
<Button
variant="outlined"
size="small"
@@ -954,6 +1017,23 @@ export default function DeviceList({ onSelectDevice }) {
hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
/>
)}
<Snackbar
open={updateStatus.open}
autoHideDuration={6000}
onClose={(_event, reason) => {
if (reason === 'clickaway') return;
setUpdateStatus((prev) => ({ ...prev, open: false }));
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setUpdateStatus((prev) => ({ ...prev, open: false }))}
severity={updateStatus.severity}
sx={{ width: '100%' }}
>
{updateStatus.message}
</Alert>
</Snackbar>
{assignDialogOpen && (
<Popover
open={assignDialogOpen}

View File

@@ -17,9 +17,10 @@ import time
import os # To Read Production ReactJS Server Folder
import json # For reading workflow JSON files
import shutil # For moving workflow files and folders
from typing import List, Dict, Tuple, Optional, Any
from typing import List, Dict, Tuple, Optional, Any, Set
import sqlite3
import io
import uuid
from datetime import datetime, timezone
try:
@@ -3125,6 +3126,48 @@ def scripts_quick_run():
return jsonify({"results": results})
@app.route("/api/agents/silent_update", methods=["POST"])
def agent_silent_update():
"""Request connected agents to run the Borealis silent update workflow."""
data = request.get_json(silent=True) or {}
raw_hosts = data.get("hostnames")
if not isinstance(raw_hosts, list):
return jsonify({"error": "hostnames[] must be provided"}), 400
hostnames: List[str] = []
seen: Set[str] = set()
for entry in raw_hosts:
name = str(entry or "").strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
hostnames.append(name)
if not hostnames:
return jsonify({"error": "No valid hostnames provided"}), 400
request_id = uuid.uuid4().hex
results: List[Dict[str, str]] = []
for host in hostnames:
payload = {
"target_hostname": host,
"request_id": request_id,
"requested_at": int(time.time()),
}
socketio.emit("agent_silent_update", payload)
results.append({"hostname": host, "status": "queued"})
_write_service_log(
"server",
f"[silent_update] request_id={request_id} dispatched to {len(hostnames)} host(s): {', '.join(hostnames)}",
)
return jsonify({"results": results, "request_id": request_id})
@app.route("/api/ansible/quick_run", methods=["POST"])
def ansible_quick_run():
"""Queue an Ansible Playbook Quick Job via WebSocket to targeted agents.
@@ -3294,6 +3337,27 @@ def handle_quick_job_result(data):
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
@socketio.on("agent_silent_update_status")
def handle_agent_silent_update_status(data):
try:
hostname = str(data.get("hostname") or "").strip()
status = str(data.get("status") or "").strip() or "unknown"
request_id = str(data.get("request_id") or "").strip()
error = str(data.get("error") or "").strip()
message_parts = [
"[silent_update_status]",
f"host={hostname or 'unknown'}",
f"status={status}",
]
if request_id:
message_parts.append(f"request_id={request_id}")
if error:
message_parts.append(f"error={error}")
_write_service_log("server", " ".join(message_parts))
except Exception:
pass
# ---------------------------------------------
# Ansible Runtime API (Play Recaps)
# ---------------------------------------------