mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 05:41:58 -06:00
Additional Attempts at Implementation of Ansible
This commit is contained in:
@@ -20,12 +20,27 @@ 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 _agent_root():
|
||||||
|
# Resolve Agent root at runtime.
|
||||||
|
# Typical runtime: <ProjectRoot>/Agent/Borealis/Roles/<this_file>
|
||||||
|
try:
|
||||||
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
# Agent/Borealis/Roles -> Agent
|
||||||
|
return os.path.abspath(os.path.join(here, '..', '..', '..'))
|
||||||
|
except Exception:
|
||||||
|
return os.path.abspath(os.path.join(_project_root(), 'Agent'))
|
||||||
|
|
||||||
|
|
||||||
def _scripts_bin():
|
def _scripts_bin():
|
||||||
# Return the venv Scripts (Windows) or bin (POSIX) path
|
# Return the venv Scripts (Windows) or bin (POSIX) path adjacent to Borealis
|
||||||
base = os.path.join(_project_root(), 'Agent', 'Scripts')
|
agent_root = _agent_root()
|
||||||
if os.path.isdir(base):
|
candidates = [
|
||||||
return base
|
os.path.join(agent_root, 'Scripts'), # Windows venv
|
||||||
# Fallback to PATH
|
os.path.join(agent_root, 'bin'), # POSIX venv
|
||||||
|
]
|
||||||
|
for base in candidates:
|
||||||
|
if os.path.isdir(base):
|
||||||
|
return base
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -38,12 +53,50 @@ def _ansible_playbook_cmd():
|
|||||||
return cand
|
return cand
|
||||||
return exe
|
return exe
|
||||||
|
|
||||||
|
def _ansible_galaxy_cmd():
|
||||||
|
exe = 'ansible-galaxy.exe' if os.name == 'nt' else 'ansible-galaxy'
|
||||||
|
sdir = _scripts_bin()
|
||||||
|
if sdir:
|
||||||
|
cand = os.path.join(sdir, exe)
|
||||||
|
if os.path.isfile(cand):
|
||||||
|
return cand
|
||||||
|
return exe
|
||||||
|
|
||||||
|
def _collections_dir():
|
||||||
|
base = os.path.join(_project_root(), 'Agent', 'Borealis', 'AnsibleCollections')
|
||||||
|
try:
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _venv_python():
|
||||||
|
try:
|
||||||
|
sdir = _scripts_bin()
|
||||||
|
if not sdir:
|
||||||
|
return None
|
||||||
|
cand = os.path.join(sdir, 'python.exe' if os.name == 'nt' else 'python3')
|
||||||
|
return cand if os.path.isfile(cand) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Role:
|
class Role:
|
||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self._runs = {} # run_id -> { proc, task, cancel }
|
self._runs = {} # run_id -> { proc, task, cancel }
|
||||||
|
|
||||||
|
def _log_local(self, msg: str, error: bool = False):
|
||||||
|
try:
|
||||||
|
base = os.path.join(_project_root(), 'Logs', 'Agent')
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
fn = 'agent.error.log' if error else 'agent.log'
|
||||||
|
ts = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
with open(os.path.join(base, fn), 'a', encoding='utf-8') as fh:
|
||||||
|
fh.write(f'[{ts}] [PlaybookExec] {msg}\n')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _server_base(self) -> str:
|
def _server_base(self) -> str:
|
||||||
try:
|
try:
|
||||||
fn = (self.ctx.hooks or {}).get('get_server_url')
|
fn = (self.ctx.hooks or {}).get('get_server_url')
|
||||||
@@ -62,8 +115,9 @@ class Role:
|
|||||||
async with sess.post(url, json=payload) as resp:
|
async with sess.post(url, json=payload) as resp:
|
||||||
# best-effort; ignore body
|
# best-effort; ignore body
|
||||||
await resp.read()
|
await resp.read()
|
||||||
|
self._log_local(f"Posted recap: run_id={payload.get('run_id')} status={payload.get('status')} bytes={len((payload.get('recap_text') or '').encode('utf-8'))}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
self._log_local(f"Failed to post recap for run_id={payload.get('run_id')}", error=True)
|
||||||
|
|
||||||
async def _run_playbook_runner(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'):
|
async def _run_playbook_runner(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'):
|
||||||
try:
|
try:
|
||||||
@@ -205,7 +259,16 @@ class Role:
|
|||||||
conn = (connection or 'local').strip().lower()
|
conn = (connection or 'local').strip().lower()
|
||||||
if conn not in ('local', 'winrm', 'psrp'):
|
if conn not in ('local', 'winrm', 'psrp'):
|
||||||
conn = 'local'
|
conn = 'local'
|
||||||
|
# Best-effort: if playbook uses ansible.windows, prefer psrp when connection left as local
|
||||||
|
if conn == 'local':
|
||||||
|
try:
|
||||||
|
if 'ansible.windows' in (playbook_content or ''):
|
||||||
|
conn = 'psrp'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn]
|
cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn]
|
||||||
|
self._log_local(f"Launching ansible-playbook: conn={conn} cmd={' '.join(cmd)}")
|
||||||
# Ensure clean, plain output and correct interpreter for localhost
|
# Ensure clean, plain output and correct interpreter for localhost
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.setdefault('ANSIBLE_FORCE_COLOR', '0')
|
env.setdefault('ANSIBLE_FORCE_COLOR', '0')
|
||||||
@@ -214,6 +277,11 @@ class Role:
|
|||||||
env.setdefault('ANSIBLE_STDOUT_CALLBACK', 'default')
|
env.setdefault('ANSIBLE_STDOUT_CALLBACK', 'default')
|
||||||
# Help Ansible pick the correct python for localhost
|
# Help Ansible pick the correct python for localhost
|
||||||
env.setdefault('ANSIBLE_LOCALHOST_WARNING', '0')
|
env.setdefault('ANSIBLE_LOCALHOST_WARNING', '0')
|
||||||
|
# Ensure collections path is discoverable
|
||||||
|
env.setdefault('ANSIBLE_COLLECTIONS_PATHS', _collections_dir())
|
||||||
|
vp = _venv_python()
|
||||||
|
if vp:
|
||||||
|
env.setdefault('ANSIBLE_PYTHON_INTERPRETER', vp)
|
||||||
|
|
||||||
creationflags = 0
|
creationflags = 0
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
@@ -222,6 +290,17 @@ class Role:
|
|||||||
|
|
||||||
proc = None
|
proc = None
|
||||||
try:
|
try:
|
||||||
|
# Best-effort collection install for windows modules
|
||||||
|
try:
|
||||||
|
if 'ansible.windows' in (playbook_content or ''):
|
||||||
|
galaxy = _ansible_galaxy_cmd()
|
||||||
|
coll_dir = _collections_dir()
|
||||||
|
creation = 0x08000000 if os.name == 'nt' else 0
|
||||||
|
self._log_local("Ensuring ansible.windows collection is installed for this agent")
|
||||||
|
subprocess.run([galaxy, 'collection', 'install', 'ansible.windows', '-p', coll_dir], timeout=120, creationflags=creation)
|
||||||
|
except Exception:
|
||||||
|
self._log_local("Collection install failed (continuing)")
|
||||||
|
|
||||||
# Prefer ansible-runner when available and enabled
|
# Prefer ansible-runner when available and enabled
|
||||||
try:
|
try:
|
||||||
if os.environ.get('BOREALIS_USE_ANSIBLE_RUNNER', '0').lower() not in ('0', 'false', 'no'):
|
if os.environ.get('BOREALIS_USE_ANSIBLE_RUNNER', '0').lower() not in ('0', 'false', 'no'):
|
||||||
@@ -240,6 +319,7 @@ class Role:
|
|||||||
creationflags=creationflags,
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self._log_local(f"Failed to launch ansible-playbook: {e}", error=True)
|
||||||
await self._post_recap({
|
await self._post_recap({
|
||||||
'run_id': run_id,
|
'run_id': run_id,
|
||||||
'hostname': hostname,
|
'hostname': hostname,
|
||||||
@@ -310,6 +390,7 @@ class Role:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
rc = proc.returncode if proc else -1
|
rc = proc.returncode if proc else -1
|
||||||
|
self._log_local(f"ansible-playbook finished rc={rc}")
|
||||||
status = 'Success' if rc == 0 else ('Cancelled' if self._runs.get(run_id, {}).get('cancel') else 'Failed')
|
status = 'Success' if rc == 0 else ('Cancelled' if self._runs.get(run_id, {}).get('cancel') else 'Failed')
|
||||||
|
|
||||||
# Final recap text
|
# Final recap text
|
||||||
|
|||||||
@@ -826,6 +826,24 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
{(String(r.script_type || '').toLowerCase() === 'ansible' && String(r.status||'') === 'Running') ? (
|
||||||
|
<Button size="small" sx={{ color: "#ff6666", textTransform: "none", minWidth: 0, p: 0 }}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/ansible/run_for_activity/${encodeURIComponent(r.id)}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
||||||
|
const run_id = data.run_id;
|
||||||
|
if (run_id) {
|
||||||
|
try { const s = window.BorealisSocket; s && s.emit('ansible_playbook_cancel', { run_id }); } catch {}
|
||||||
|
} else {
|
||||||
|
alert('Unable to locate run id for this playbook run.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(String(e.message || e));
|
||||||
|
}
|
||||||
|
}}>Cancel</Button>
|
||||||
|
) : null}
|
||||||
{r.has_stdout ? (
|
{r.has_stdout ? (
|
||||||
<Button size="small" onClick={() => handleViewOutput(r, 'stdout')} sx={{ color: "#58a6ff", textTransform: "none", minWidth: 0, p: 0 }}>
|
<Button size="small" onClick={() => handleViewOutput(r, 'stdout')} sx={{ color: "#58a6ff", textTransform: "none", minWidth: 0, p: 0 }}>
|
||||||
StdOut
|
StdOut
|
||||||
|
|||||||
@@ -2566,7 +2566,34 @@ def ansible_quick_run():
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
for host in hostnames:
|
for host in hostnames:
|
||||||
run_id = None
|
# Create activity_history row so UI shows running state and can receive recap mirror
|
||||||
|
job_id = None
|
||||||
|
try:
|
||||||
|
conn2 = _db_conn()
|
||||||
|
cur2 = conn2.cursor()
|
||||||
|
now_ts = int(time.time())
|
||||||
|
cur2.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(host),
|
||||||
|
rel_path.replace(os.sep, "/"),
|
||||||
|
os.path.basename(abs_path),
|
||||||
|
"ansible",
|
||||||
|
now_ts,
|
||||||
|
"Running",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
job_id = cur2.lastrowid
|
||||||
|
conn2.commit()
|
||||||
|
conn2.close()
|
||||||
|
except Exception:
|
||||||
|
job_id = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
run_id = _uuid.uuid4().hex
|
run_id = _uuid.uuid4().hex
|
||||||
@@ -2578,12 +2605,13 @@ def ansible_quick_run():
|
|||||||
"playbook_name": os.path.basename(abs_path),
|
"playbook_name": os.path.basename(abs_path),
|
||||||
"playbook_content": content,
|
"playbook_content": content,
|
||||||
"connection": "local",
|
"connection": "local",
|
||||||
|
"activity_job_id": job_id,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
socketio.emit("ansible_playbook_run", payload)
|
socketio.emit("ansible_playbook_run", payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
results.append({"hostname": host, "run_id": run_id, "status": "Queued"})
|
results.append({"hostname": host, "run_id": run_id, "status": "Queued", "activity_job_id": job_id})
|
||||||
return jsonify({"results": results})
|
return jsonify({"results": results})
|
||||||
except ValueError as ve:
|
except ValueError as ve:
|
||||||
return jsonify({"error": str(ve)}), 400
|
return jsonify({"error": str(ve)}), 400
|
||||||
@@ -2839,6 +2867,26 @@ def api_ansible_recap_report():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Reflect into scheduled_job_runs if linked
|
||||||
|
try:
|
||||||
|
if scheduled_job_id and scheduled_run_id:
|
||||||
|
st = (status or '').strip()
|
||||||
|
ts_now = now
|
||||||
|
# If Running, update status/started_ts if needed; otherwise mark finished + status
|
||||||
|
if st.lower() == 'running':
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status='Running', updated_at=?, started_ts=COALESCE(started_ts, ?) WHERE id=? AND job_id=?",
|
||||||
|
(ts_now, started_ts or ts_now, int(scheduled_run_id), int(scheduled_job_id))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status=?, finished_ts=COALESCE(?, finished_ts, ?), updated_at=? WHERE id=? AND job_id=?",
|
||||||
|
(st or 'Success', finished_ts, ts_now, ts_now, int(scheduled_run_id), int(scheduled_job_id))
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Return the latest row
|
# Return the latest row
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
|
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
|
||||||
@@ -2980,6 +3028,31 @@ def api_ansible_recap_get(recap_id: int):
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/run_for_activity/<int:activity_id>", methods=["GET"])
|
||||||
|
def api_ansible_run_for_activity(activity_id: int):
|
||||||
|
"""Return the latest run_id/status for a recap row linked to an activity_history id."""
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT run_id, status
|
||||||
|
FROM ansible_play_recaps
|
||||||
|
WHERE activity_job_id = ?
|
||||||
|
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(activity_id,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
return jsonify({"run_id": row[0], "status": row[1] or ""})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@socketio.on("collector_status")
|
@socketio.on("collector_status")
|
||||||
def handle_collector_status(data):
|
def handle_collector_status(data):
|
||||||
"""Collector agent reports activity and optional last_user.
|
"""Collector agent reports activity and optional last_user.
|
||||||
|
|||||||
Reference in New Issue
Block a user