from __future__ import annotations from typing import Any from sqlmodel import Session from ..connectors.unraid import UnraidConnector from .approval_service import create_approval from .audit_logger import log_event from .job_runner import complete_job, create_job, set_job_progress, start_job from .policy_engine import evaluate_policy from .setup_service import apply_stored_connector_settings VM_ACTIONS: dict[str, dict[str, Any]] = { 'start': { 'action_id': 'unraid.vm.start', 'expected_before': {'SHUTOFF', 'STOPPED', 'OFFLINE'}, 'expected_after': {'RUNNING', 'STARTED', 'ONLINE'}, 'risk': 'medium', 'label': 'Start VM', }, 'resume': { 'action_id': 'unraid.vm.resume', 'expected_before': {'PAUSED', 'PMSUSPENDED'}, 'expected_after': {'RUNNING', 'STARTED', 'ONLINE'}, 'risk': 'medium', 'label': 'Resume VM', }, 'pause': { 'action_id': 'unraid.vm.pause', 'expected_before': {'RUNNING', 'STARTED', 'ONLINE'}, 'expected_after': {'PAUSED'}, 'risk': 'high', 'label': 'Pause VM', }, 'stop': { 'action_id': 'unraid.vm.stop', 'risk': 'critical', 'label': 'Stop VM', 'blocked_reason': ( 'The current Unraid stop mutation can force-stop a VM after its graceful-shutdown timeout. ' 'AppOps keeps this destructive fallback blocked.' ), }, 'reboot': { 'action_id': 'unraid.vm.reboot', 'risk': 'critical', 'label': 'Reboot VM', 'blocked_reason': 'VM reboot remains blocked until guest-shutdown and recovery semantics can be verified safely.', }, 'force_stop': { 'action_id': 'unraid.vm.force_stop', 'risk': 'critical', 'label': 'Force-stop VM', 'blocked_reason': 'Force-stop is destructive and blocked by default.', }, 'reset': { 'action_id': 'unraid.vm.reset', 'risk': 'critical', 'label': 'Reset VM', 'blocked_reason': 'VM reset is destructive and blocked by default.', }, } def _find_vm(connector: UnraidConnector, vm_id: str) -> tuple[dict[str, Any] | None, dict[str, Any]]: inventory = connector.get_vm_summary() if inventory.get('status') != 'ok': return None, inventory vm = next((item for item in inventory.get('vms', []) if str(item.get('id')) == vm_id), None) return vm, inventory def vm_capabilities(session: Session) -> dict[str, Any]: actions = [] for action, definition in VM_ACTIONS.items(): action_id = str(definition['action_id']) policy = evaluate_policy(session, action=action_id, target='*', source='manual') blocked_reason = definition.get('blocked_reason') actions.append({ 'id': action, 'actionId': action_id, 'label': definition['label'], 'risk': definition['risk'], 'policyDecision': 'blocked' if blocked_reason else policy['decision'], 'reason': blocked_reason or policy['reason'], 'executionSupported': action in {'start', 'resume', 'pause'}, }) return { 'source': 'unraid_graphql', 'writePermission': 'vm:update or vm:*', 'actions': actions, } def request_vm_action( session: Session, *, vm_id: str, action: str, source: str = 'manual', confirmed: bool = False, ) -> dict[str, Any]: normalized_action = str(action).strip().lower() definition = VM_ACTIONS.get(normalized_action) if not definition: return { 'success': False, 'status': 'blocked', 'result': 'blocked', 'decision': 'blocked', 'message': 'Unknown VM action.', } action_id = str(definition['action_id']) risk = str(definition['risk']) blocked_reason = definition.get('blocked_reason') if blocked_reason: audit = log_event( session, source=source, identity='operator', action_id=action_id, target=vm_id, target_type='virtual_machine', risk=risk, policy_decision='blocked', reason=str(blocked_reason), result='blocked', execution_plan={'steps': []}, redacted_metadata={'vmId': vm_id, 'requestedAction': normalized_action}, ) return { 'success': False, 'status': 'blocked', 'result': 'blocked', 'decision': 'blocked', 'policy_decision': 'blocked', 'message': blocked_reason, 'auditId': audit.id, } policy = evaluate_policy(session, action=action_id, target=vm_id, source=source) if policy['decision'] == 'blocked': return { 'success': False, 'status': 'blocked', 'result': 'blocked', 'decision': 'blocked', 'policy_decision': 'blocked', 'message': policy['reason'], } if policy['decision'] == 'approval_required': approval = create_approval( session, action=action_id, target=vm_id, source=source, risk=risk, reason=policy['reason'], requester=source, ) audit = log_event( session, source=source, identity='operator', action_id=action_id, target=vm_id, target_type='virtual_machine', risk=risk, policy_decision='approval_required', reason=policy['reason'], result='approval_requested', execution_plan={'steps': ['Review VM impact', 'Approve request', 'Execute and verify state']}, redacted_metadata={'vmId': vm_id, 'approvalId': approval.id}, ) return { 'success': False, 'status': 'approval_required', 'result': 'approval_required', 'decision': 'approval_required', 'policy_decision': 'approval_required', 'message': 'VM action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id, } if not confirmed: return { 'success': False, 'status': 'confirm_required', 'result': 'confirm_required', 'decision': 'allowed', 'policy_decision': 'allowed', 'message': 'Explicit confirmation is required before changing VM state.', } return execute_vm_action( session, vm_id=vm_id, action=normalized_action, source=source, policy_decision='allowed', reason=policy['reason'], ) def execute_vm_action( session: Session, *, vm_id: str, action: str, source: str, policy_decision: str, reason: str, ) -> dict[str, Any]: definition = VM_ACTIONS.get(action) if not definition or action not in {'start', 'resume', 'pause'}: return { 'success': False, 'status': 'blocked', 'result': 'blocked', 'message': 'No safe VM executor is registered for this action.', } apply_stored_connector_settings(session) connector = UnraidConnector() before_vm, inventory = _find_vm(connector, vm_id) if not before_vm: message = str(inventory.get('reason') or 'VM target was not found in live Unraid inventory.') audit = log_event( session, source=source, identity='operator', action_id=str(definition['action_id']), target=vm_id, target_type='virtual_machine', risk=str(definition['risk']), policy_decision=policy_decision, reason=reason, result='failed', before_state={'inventoryStatus': inventory.get('status')}, redacted_metadata={'vmId': vm_id}, ) return { 'success': False, 'status': 'failed', 'result': 'failed', 'message': message, 'auditId': audit.id, } before_state = str(before_vm.get('state', 'unknown')).upper() if before_state not in definition['expected_before']: message = f'{definition["label"]} is not valid while VM state is {before_state}.' audit = log_event( session, source=source, identity='operator', action_id=str(definition['action_id']), target=vm_id, target_type='virtual_machine', risk=str(definition['risk']), policy_decision=policy_decision, reason=reason, result='blocked', before_state={'name': before_vm.get('name'), 'state': before_state}, redacted_metadata={'vmId': vm_id}, ) return { 'success': False, 'status': 'invalid_state', 'result': 'blocked', 'message': message, 'auditId': audit.id, } steps = [ 'Load live VM inventory', f'Validate {before_state} state', f'Run {definition["label"]} through Unraid GraphQL', 'Reload VM inventory and verify state', ] job = create_job( session, action_id=str(definition['action_id']), target=vm_id, source=source, risk=str(definition['risk']), policy_decision=policy_decision, steps=steps, ) start_job(session, job) set_job_progress(session, job, 35, f'Validated VM {before_vm.get("name", vm_id)} in state {before_state}.') mutation = connector.mutate_vm(vm_id, action) set_job_progress(session, job, 75, str(mutation.get('message') or 'VM mutation returned.')) after_vm, after_inventory = _find_vm(connector, vm_id) after_state = str(after_vm.get('state', 'unknown')).upper() if after_vm else 'UNKNOWN' verified = after_state in definition['expected_after'] success = bool(mutation.get('success')) and verified if success: message = f'{before_vm.get("name", "VM")} is now {after_state.lower()}.' elif mutation.get('success'): message = f'Unraid accepted the action, but post-check reported {after_state}.' else: message = str(mutation.get('message') or 'VM action failed.') completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message) audit = log_event( session, source=source, identity='operator', action_id=str(definition['action_id']), target=vm_id, target_type='virtual_machine', risk=str(definition['risk']), policy_decision=policy_decision, reason=reason, result='success' if success else 'failed', execution_plan={'steps': steps, 'jobId': completed.id}, before_state={'name': before_vm.get('name'), 'state': before_state}, after_state={ 'name': after_vm.get('name') if after_vm else before_vm.get('name'), 'state': after_state, 'inventoryStatus': after_inventory.get('status'), 'verified': verified, }, redacted_metadata={'vmId': vm_id, 'executor': definition['action_id']}, ) return { 'success': success, 'status': 'success' if success else 'failed', 'result': 'success' if success else 'failed', 'decision': policy_decision, 'policy_decision': policy_decision, 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'before_state': {'name': before_vm.get('name'), 'state': before_state}, 'after_state': {'state': after_state, 'verified': verified}, } def execute_approved_vm_action(session: Session, approval) -> dict[str, Any]: action = next( (name for name, definition in VM_ACTIONS.items() if definition['action_id'] == approval.action), None, ) if action not in {'start', 'resume', 'pause'}: return { 'success': False, 'status': 'failed', 'result': 'unsupported', 'message': 'No safe approved VM executor is registered for this action.', } return execute_vm_action( session, vm_id=approval.target, action=action, source='approved_operator', policy_decision='approval_required', reason=approval.reason or f'Approved through {approval.id}.', )