364 lines
12 KiB
Python
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}.',
|
|
)
|