mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 00:45:48 -07:00
Add silent update trigger for agents
This commit is contained in:
@@ -17,6 +17,76 @@ def _project_root():
|
|||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
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:
|
def _canonical_env_key(name: str) -> str:
|
||||||
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
|
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
|
||||||
return cleaned.upper()
|
return cleaned.upper()
|
||||||
@@ -258,6 +328,46 @@ class Role:
|
|||||||
def register_events(self):
|
def register_events(self):
|
||||||
sio = self.ctx.sio
|
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')
|
@sio.on('quick_job_run')
|
||||||
async def _on_quick_job_run(payload):
|
async def _on_quick_job_run(payload):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Popover,
|
Popover,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
Snackbar,
|
||||||
|
Alert
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
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
|
// Track selection by agent id to avoid duplicate hostname collisions
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
||||||
|
const [updateLoading, setUpdateLoading] = useState(false);
|
||||||
|
const [updateStatus, setUpdateStatus] = useState({ open: false, severity: "success", message: "" });
|
||||||
|
|
||||||
// Saved custom views (from server)
|
// Saved custom views (from server)
|
||||||
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
|
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
|
// Column drag handlers
|
||||||
const onHeaderDragStart = (colId) => (e) => {
|
const onHeaderDragStart = (colId) => (e) => {
|
||||||
dragColId.current = colId;
|
dragColId.current = colId;
|
||||||
@@ -572,7 +619,23 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Second row: Quick Job button aligned under header title */}
|
{/* 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
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -954,6 +1017,23 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
|
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 && (
|
{assignDialogOpen && (
|
||||||
<Popover
|
<Popover
|
||||||
open={assignDialogOpen}
|
open={assignDialogOpen}
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import time
|
|||||||
import os # To Read Production ReactJS Server Folder
|
import os # To Read Production ReactJS Server Folder
|
||||||
import json # For reading workflow JSON files
|
import json # For reading workflow JSON files
|
||||||
import shutil # For moving workflow files and folders
|
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 sqlite3
|
||||||
import io
|
import io
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3125,6 +3126,48 @@ def scripts_quick_run():
|
|||||||
return jsonify({"results": results})
|
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"])
|
@app.route("/api/ansible/quick_run", methods=["POST"])
|
||||||
def ansible_quick_run():
|
def ansible_quick_run():
|
||||||
"""Queue an Ansible Playbook Quick Job via WebSocket to targeted agents.
|
"""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}")
|
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)
|
# Ansible Runtime API (Play Recaps)
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user