mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 23:21:57 -06:00
Pass stored WinRM credentials to agent Ansible runs
This commit is contained in:
@@ -136,7 +136,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
const list = Array.isArray(data?.credentials)
|
const list = Array.isArray(data?.credentials)
|
||||||
? data.credentials.filter((cred) => String(cred.connection_type || "").toLowerCase() === "ssh")
|
? data.credentials.filter((cred) => {
|
||||||
|
const conn = String(cred.connection_type || "").toLowerCase();
|
||||||
|
return conn === "ssh" || conn === "winrm";
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
||||||
setCredentials(list);
|
setCredentials(list);
|
||||||
@@ -435,11 +438,15 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
>
|
>
|
||||||
{credentials.map((cred) => (
|
{credentials.map((cred) => {
|
||||||
|
const conn = String(cred.connection_type || "").toUpperCase();
|
||||||
|
return (
|
||||||
<MenuItem key={cred.id} value={String(cred.id)}>
|
<MenuItem key={cred.id} value={String(cred.id)}>
|
||||||
{cred.name}
|
{cred.name}
|
||||||
|
{conn ? ` (${conn})` : ""}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
||||||
@@ -448,7 +455,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
)}
|
)}
|
||||||
{!credentialsLoading && !credentialsError && !credentials.length && (
|
{!credentialsLoading && !credentialsError && !credentials.length && (
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
||||||
No SSH credentials available. Create one under Access Management.
|
No SSH or WinRM credentials available. Create one under Access Management.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Callable
|
from typing import Any, Dict, List, Optional, Tuple, Callable
|
||||||
|
|
||||||
|
_WINRM_USERNAME_VAR = "__borealis_winrm_username"
|
||||||
|
_WINRM_PASSWORD_VAR = "__borealis_winrm_password"
|
||||||
|
_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Job Scheduler module for Borealis
|
Job Scheduler module for Borealis
|
||||||
|
|
||||||
@@ -54,6 +58,26 @@ def _decode_base64_text(value: Any) -> Optional[str]:
|
|||||||
return decoded.decode("utf-8", errors="replace")
|
return decoded.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_winrm_credential(
|
||||||
|
base_values: Optional[Dict[str, Any]],
|
||||||
|
credential: Optional[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
values: Dict[str, Any] = dict(base_values or {})
|
||||||
|
if not credential:
|
||||||
|
return values
|
||||||
|
|
||||||
|
username = str(credential.get("username") or "")
|
||||||
|
password = str(credential.get("password") or "")
|
||||||
|
metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {}
|
||||||
|
transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None
|
||||||
|
transport_str = str(transport or "ntlm").strip().lower() or "ntlm"
|
||||||
|
|
||||||
|
values[_WINRM_USERNAME_VAR] = username
|
||||||
|
values[_WINRM_PASSWORD_VAR] = password
|
||||||
|
values[_WINRM_TRANSPORT_VAR] = transport_str
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
|
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
|
||||||
encoding = (encoding_hint or "").strip().lower()
|
encoding = (encoding_hint or "").strip().lower()
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -311,6 +335,8 @@ class JobScheduler:
|
|||||||
self._online_lookup: Optional[Callable[[], List[str]]] = None
|
self._online_lookup: Optional[Callable[[], List[str]]] = None
|
||||||
# Optional callback to execute Ansible directly from the server
|
# Optional callback to execute Ansible directly from the server
|
||||||
self._server_ansible_runner: Optional[Callable[..., str]] = None
|
self._server_ansible_runner: Optional[Callable[..., str]] = None
|
||||||
|
# Optional callback to fetch stored credentials (with decrypted secrets)
|
||||||
|
self._credential_fetcher: Optional[Callable[[int], Optional[Dict[str, Any]]]] = None
|
||||||
|
|
||||||
# Ensure run-history table exists
|
# Ensure run-history table exists
|
||||||
self._init_tables()
|
self._init_tables()
|
||||||
@@ -522,7 +548,24 @@ class JobScheduler:
|
|||||||
variables = doc.get("variables") or []
|
variables = doc.get("variables") or []
|
||||||
files = doc.get("files") or []
|
files = doc.get("files") or []
|
||||||
run_mode_norm = (run_mode or "system").strip().lower()
|
run_mode_norm = (run_mode or "system").strip().lower()
|
||||||
server_run = run_mode_norm in ("ssh", "winrm")
|
server_run = run_mode_norm == "ssh"
|
||||||
|
agent_winrm = run_mode_norm == "winrm"
|
||||||
|
|
||||||
|
if agent_winrm:
|
||||||
|
if not credential_id:
|
||||||
|
raise RuntimeError("WinRM execution requires a credential_id")
|
||||||
|
if not callable(self._credential_fetcher):
|
||||||
|
raise RuntimeError("Credential fetcher is not configured")
|
||||||
|
cred_detail = self._credential_fetcher(int(credential_id))
|
||||||
|
if not cred_detail:
|
||||||
|
raise RuntimeError("Credential not found")
|
||||||
|
try:
|
||||||
|
overrides_map = _inject_winrm_credential(overrides_map, cred_detail)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
cred_detail.clear() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Record in activity_history for UI parity
|
# Record in activity_history for UI parity
|
||||||
now = _now_ts()
|
now = _now_ts()
|
||||||
@@ -743,6 +786,9 @@ class JobScheduler:
|
|||||||
def _conn(self):
|
def _conn(self):
|
||||||
return sqlite3.connect(self.db_path)
|
return sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
def set_credential_fetcher(self, fn: Optional[Callable[[int], Optional[Dict[str, Any]]]]):
|
||||||
|
self._credential_fetcher = fn
|
||||||
|
|
||||||
def _init_tables(self):
|
def _init_tables(self):
|
||||||
conn = self._conn()
|
conn = self._conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -1738,3 +1784,7 @@ def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]):
|
|||||||
|
|
||||||
def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]):
|
def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]):
|
||||||
scheduler._server_ansible_runner = fn
|
scheduler._server_ansible_runner = fn
|
||||||
|
|
||||||
|
|
||||||
|
def set_credential_fetcher(scheduler: JobScheduler, fn: Callable[[int], Optional[Dict[str, Any]]]):
|
||||||
|
scheduler._credential_fetcher = fn
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ from Python_API_Endpoints.script_engines import run_powershell_script
|
|||||||
from job_scheduler import register as register_job_scheduler
|
from job_scheduler import register as register_job_scheduler
|
||||||
from job_scheduler import set_online_lookup as scheduler_set_online_lookup
|
from job_scheduler import set_online_lookup as scheduler_set_online_lookup
|
||||||
from job_scheduler import set_server_ansible_runner as scheduler_set_server_runner
|
from job_scheduler import set_server_ansible_runner as scheduler_set_server_runner
|
||||||
|
from job_scheduler import set_credential_fetcher as scheduler_set_credential_fetcher
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Section: Runtime Stack Configuration
|
# Section: Runtime Stack Configuration
|
||||||
@@ -1859,6 +1860,11 @@ def _ensure_ansible_workspace() -> str:
|
|||||||
return _ANSIBLE_WORKSPACE_DIR
|
return _ANSIBLE_WORKSPACE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
_WINRM_USERNAME_VAR = "__borealis_winrm_username"
|
||||||
|
_WINRM_PASSWORD_VAR = "__borealis_winrm_password"
|
||||||
|
_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport"
|
||||||
|
|
||||||
|
|
||||||
def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any]]:
|
def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
conn = _db_conn()
|
conn = _db_conn()
|
||||||
@@ -1876,7 +1882,8 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any
|
|||||||
private_key_passphrase_encrypted,
|
private_key_passphrase_encrypted,
|
||||||
become_method,
|
become_method,
|
||||||
become_username,
|
become_username,
|
||||||
become_password_encrypted
|
become_password_encrypted,
|
||||||
|
metadata_json
|
||||||
FROM credentials
|
FROM credentials
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
""",
|
""",
|
||||||
@@ -1900,8 +1907,40 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any
|
|||||||
"become_method": _normalize_become_method(row[8]),
|
"become_method": _normalize_become_method(row[8]),
|
||||||
"become_username": row[9] or "",
|
"become_username": row[9] or "",
|
||||||
"become_password": _decrypt_secret(row[10]) if row[10] else "",
|
"become_password": _decrypt_secret(row[10]) if row[10] else "",
|
||||||
|
"metadata": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta_json = row[11] if len(row) > 11 else None
|
||||||
|
if meta_json:
|
||||||
|
meta = json.loads(meta_json)
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
cred["metadata"] = meta
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return cred
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_winrm_credential(
|
||||||
|
base_values: Optional[Dict[str, Any]],
|
||||||
|
credential: Optional[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
values: Dict[str, Any] = dict(base_values or {})
|
||||||
|
if not credential:
|
||||||
|
return values
|
||||||
|
|
||||||
|
username = str(credential.get("username") or "")
|
||||||
|
password = str(credential.get("password") or "")
|
||||||
|
metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {}
|
||||||
|
transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None
|
||||||
|
transport_str = str(transport or "ntlm").strip().lower() or "ntlm"
|
||||||
|
|
||||||
|
values[_WINRM_USERNAME_VAR] = username
|
||||||
|
values[_WINRM_PASSWORD_VAR] = password
|
||||||
|
values[_WINRM_TRANSPORT_VAR] = transport_str
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _emit_ansible_recap_from_row(row):
|
def _emit_ansible_recap_from_row(row):
|
||||||
if not row:
|
if not row:
|
||||||
@@ -4564,6 +4603,7 @@ ensure_default_admin()
|
|||||||
|
|
||||||
job_scheduler = register_job_scheduler(app, socketio, DB_PATH)
|
job_scheduler = register_job_scheduler(app, socketio, DB_PATH)
|
||||||
scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run)
|
scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run)
|
||||||
|
scheduler_set_credential_fetcher(job_scheduler, _fetch_credential_with_secrets)
|
||||||
job_scheduler.start()
|
job_scheduler.start()
|
||||||
|
|
||||||
# Provide scheduler with online device lookup based on registered agents
|
# Provide scheduler with online device lookup based on registered agents
|
||||||
@@ -6375,15 +6415,26 @@ def ansible_quick_run():
|
|||||||
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
|
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
|
||||||
server_mode = False
|
server_mode = False
|
||||||
cred_id_int = None
|
cred_id_int = None
|
||||||
|
credential_detail: Optional[Dict[str, Any]] = None
|
||||||
if credential_id not in (None, "", "null"):
|
if credential_id not in (None, "", "null"):
|
||||||
try:
|
try:
|
||||||
cred_id_int = int(credential_id)
|
cred_id_int = int(credential_id)
|
||||||
if cred_id_int <= 0:
|
if cred_id_int <= 0:
|
||||||
cred_id_int = None
|
cred_id_int = None
|
||||||
else:
|
|
||||||
server_mode = True
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "Invalid credential_id"}), 400
|
return jsonify({"error": "Invalid credential_id"}), 400
|
||||||
|
|
||||||
|
if cred_id_int:
|
||||||
|
credential_detail = _fetch_credential_with_secrets(cred_id_int)
|
||||||
|
if not credential_detail:
|
||||||
|
return jsonify({"error": "Credential not found"}), 404
|
||||||
|
conn_type = (credential_detail.get("connection_type") or "ssh").lower()
|
||||||
|
if conn_type in ("ssh", "linux", "unix"):
|
||||||
|
server_mode = True
|
||||||
|
elif conn_type in ("winrm", "psrp"):
|
||||||
|
variable_values = _inject_winrm_credential(variable_values, credential_detail)
|
||||||
|
else:
|
||||||
|
return jsonify({"error": f"Credential connection '{conn_type}' not supported"}), 400
|
||||||
try:
|
try:
|
||||||
root, abs_path, _ = _resolve_assembly_path('ansible', rel_path)
|
root, abs_path, _ = _resolve_assembly_path('ansible', rel_path)
|
||||||
if not os.path.isfile(abs_path):
|
if not os.path.isfile(abs_path):
|
||||||
@@ -6407,16 +6458,6 @@ def ansible_quick_run():
|
|||||||
if server_mode and not cred_id_int:
|
if server_mode and not cred_id_int:
|
||||||
return jsonify({"error": "credential_id is required for server-side execution"}), 400
|
return jsonify({"error": "credential_id is required for server-side execution"}), 400
|
||||||
|
|
||||||
if server_mode:
|
|
||||||
cred = _fetch_credential_with_secrets(cred_id_int)
|
|
||||||
if not cred:
|
|
||||||
return jsonify({"error": "Credential not found"}), 404
|
|
||||||
conn_type = (cred.get("connection_type") or "ssh").lower()
|
|
||||||
if conn_type not in ("ssh",):
|
|
||||||
return jsonify({"error": f"Credential connection '{conn_type}' not supported for server execution"}), 400
|
|
||||||
# Avoid keeping decrypted secrets in memory longer than necessary
|
|
||||||
del cred
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for host in hostnames:
|
for host in hostnames:
|
||||||
# Create activity_history row so UI shows running state and can receive recap mirror
|
# Create activity_history row so UI shows running state and can receive recap mirror
|
||||||
@@ -6510,6 +6551,9 @@ def ansible_quick_run():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
results.append({"hostname": host, "run_id": run_id, "status": "Failed", "activity_job_id": job_id, "error": str(ex)})
|
results.append({"hostname": host, "run_id": run_id, "status": "Failed", "activity_job_id": job_id, "error": str(ex)})
|
||||||
|
if credential_detail is not None:
|
||||||
|
# Remove decrypted secrets from scope as soon as possible
|
||||||
|
credential_detail.clear()
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user