Files
Unraid-AppOps-Gateway-Public/backend/app/services/unraid_vm_service.py
T

364 lines
12 KiB
Python

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}.',
)