647 lines
27 KiB
Python
647 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..connectors.portainer import PortainerConnector
|
|
from ..connectors.unraid import UnraidConnector
|
|
from ..core.config import settings
|
|
from ..core.redaction import redact_message
|
|
from ..core.time import utc_now
|
|
from ..core.network_security import validate_connector_url
|
|
from ..db.models import AppContainerMapping, AppSetting, AuditEvent, Connector, DiscoveryRun, Secret
|
|
from .audit_logger import log_event
|
|
from .secret_store import empty_secret_status, get_secret, has_secret, secret_status, secret_storage_status, set_secret
|
|
from . import safety_center_service
|
|
|
|
|
|
_CONNECTOR_FIELDS: dict[str, dict[str, Any]] = {
|
|
'portainer': {
|
|
'required': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'],
|
|
'secret_env': 'PORTAINER_API_KEY',
|
|
'field_env': {'url': 'PORTAINER_URL', 'endpointId': 'PORTAINER_ENDPOINT_ID', 'verifyTls': 'PORTAINER_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'endpointId', 'verifyTls'],
|
|
},
|
|
'unraid': {
|
|
'required': ['UNRAID_URL', 'UNRAID_API_KEY'],
|
|
'secret_env': 'UNRAID_API_KEY',
|
|
'field_env': {'url': 'UNRAID_URL', 'verifyTls': 'UNRAID_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'sonarr': {
|
|
'required': ['SONARR_URL', 'SONARR_API_KEY'],
|
|
'secret_env': 'SONARR_API_KEY',
|
|
'field_env': {'url': 'SONARR_URL'},
|
|
'safe_fields': ['url'],
|
|
},
|
|
'radarr': {
|
|
'required': ['RADARR_URL', 'RADARR_API_KEY'],
|
|
'secret_env': 'RADARR_API_KEY',
|
|
'field_env': {'url': 'RADARR_URL', 'verifyTls': 'RADARR_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'prowlarr': {
|
|
'required': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
|
|
'secret_env': 'PROWLARR_API_KEY',
|
|
'field_env': {'url': 'PROWLARR_URL', 'verifyTls': 'PROWLARR_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'deluge': {
|
|
'required': ['DELUGE_URL', 'DELUGE_PASSWORD'],
|
|
'secret_env': 'DELUGE_PASSWORD',
|
|
'field_env': {'url': 'DELUGE_URL', 'verifyTls': 'DELUGE_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'plex': {
|
|
'required': ['PLEX_URL', 'PLEX_TOKEN'],
|
|
'secret_env': 'PLEX_TOKEN',
|
|
'field_env': {'url': 'PLEX_URL', 'verifyTls': 'PLEX_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'tautulli': {
|
|
'required': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
|
|
'secret_env': 'TAUTULLI_API_KEY',
|
|
'field_env': {'url': 'TAUTULLI_URL', 'verifyTls': 'TAUTULLI_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'nginx_proxy_manager': {
|
|
'required': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'],
|
|
'secret_env': 'NPM_SECRET',
|
|
'field_env': {'url': 'NPM_URL', 'identity': 'NPM_IDENTITY', 'verifyTls': 'NPM_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'identity', 'verifyTls'],
|
|
},
|
|
'authentik': {
|
|
'required': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'],
|
|
'secret_env': 'AUTHENTIK_TOKEN',
|
|
'field_env': {
|
|
'url': 'AUTHENTIK_URL',
|
|
'verifyTls': 'AUTHENTIK_VERIFY_TLS',
|
|
'outpostUrl': 'AUTHENTIK_OUTPOST_URL',
|
|
'expectEmbeddedOutpost': 'AUTHENTIK_EXPECT_EMBEDDED_OUTPOST',
|
|
},
|
|
'safe_fields': ['url', 'verifyTls', 'outpostUrl', 'expectEmbeddedOutpost'],
|
|
},
|
|
'widefrog': {
|
|
'required': ['WIDEFROG_URL'],
|
|
'secret_env': 'WIDEFROG_API_KEY',
|
|
'field_env': {'url': 'WIDEFROG_URL', 'verifyTls': 'WIDEFROG_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
'home_assistant': {
|
|
'required': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'],
|
|
'secret_env': 'HOME_ASSISTANT_TOKEN',
|
|
'field_env': {'url': 'HOME_ASSISTANT_URL', 'verifyTls': 'HOME_ASSISTANT_VERIFY_TLS'},
|
|
'safe_fields': ['url', 'verifyTls'],
|
|
},
|
|
}
|
|
|
|
|
|
def _setting_key(connector_id: str, suffix: str) -> str:
|
|
return f'connector:{connector_id}:{suffix}'
|
|
|
|
|
|
def _get_setting(session: Session, key: str) -> AppSetting | None:
|
|
return session.get(AppSetting, key)
|
|
|
|
|
|
def _set_setting(session: Session, key: str, value: dict[str, Any], is_secret: bool = False) -> AppSetting:
|
|
row = _get_setting(session, key)
|
|
if not row:
|
|
row = AppSetting(key=key, is_secret=is_secret)
|
|
row.value_json = value
|
|
row.is_secret = is_secret
|
|
row.updated_at = utc_now()
|
|
session.add(row)
|
|
return row
|
|
|
|
|
|
def _connector_schema(connector_id: str) -> dict[str, Any]:
|
|
return _CONNECTOR_FIELDS.get(connector_id, {
|
|
'required': [f'{connector_id.upper()}_URL'],
|
|
'secret_env': f'{connector_id.upper()}_API_KEY',
|
|
'field_env': {'url': f'{connector_id.upper()}_URL'},
|
|
'safe_fields': ['url'],
|
|
})
|
|
|
|
|
|
def _safe_config_from_row(session: Session, connector_id: str) -> dict[str, Any]:
|
|
row = _get_setting(session, _setting_key(connector_id, 'config'))
|
|
config = dict(row.value_json) if row else {}
|
|
schema = _connector_schema(connector_id)
|
|
safe: dict[str, Any] = {}
|
|
for field in schema['safe_fields']:
|
|
value = config.get(field)
|
|
if value is None:
|
|
env_name = schema['field_env'].get(field)
|
|
value = settings.apis.get(env_name) if env_name else None
|
|
if value is not None:
|
|
safe[field] = value
|
|
if connector_id == 'portainer' and not str(safe.get('endpointId') or '').strip():
|
|
safe['endpointId'] = '1'
|
|
return safe
|
|
|
|
|
|
def _has_secret(session: Session, connector_id: str) -> bool:
|
|
schema = _connector_schema(connector_id)
|
|
secret_row = _get_setting(session, _setting_key(connector_id, 'secret'))
|
|
secret_env = schema.get('secret_env')
|
|
return bool(secret_env and has_secret(session, secret_env)) or bool(secret_row and secret_row.value_json.get('value')) or bool(secret_env and settings.apis.get(secret_env))
|
|
|
|
|
|
def _connector_secret_status(session: Session, connector_id: str) -> dict[str, Any]:
|
|
schema = _connector_schema(connector_id)
|
|
secret_env = schema.get('secret_env')
|
|
if not secret_env:
|
|
return empty_secret_status(f'{connector_id.upper()}_API_KEY', used_by=[connector_id])
|
|
row = session.get(Secret, secret_env)
|
|
if row:
|
|
return secret_status(row)
|
|
if settings.apis.get(secret_env):
|
|
status = empty_secret_status(secret_env, used_by=[connector_id])
|
|
status['hasSecret'] = True
|
|
status['status'] = 'env_fallback'
|
|
status['lastUpdated'] = None
|
|
return status
|
|
return empty_secret_status(secret_env, used_by=[connector_id])
|
|
|
|
|
|
def _migrate_legacy_connector_secret(session: Session, connector_id: str) -> None:
|
|
schema = _connector_schema(connector_id)
|
|
secret_env = schema.get('secret_env')
|
|
if not secret_env:
|
|
return
|
|
legacy_row = _get_setting(session, _setting_key(connector_id, 'secret'))
|
|
legacy_value = legacy_row.value_json.get('value') if legacy_row else None
|
|
if not isinstance(legacy_value, str) or not legacy_value.strip():
|
|
return
|
|
set_secret(
|
|
session,
|
|
secret_env,
|
|
legacy_value,
|
|
used_by=[connector_id],
|
|
metadata={'connectorId': connector_id, 'migratedFrom': _setting_key(connector_id, 'secret')},
|
|
commit=False,
|
|
)
|
|
legacy_row.value_json = {'secretRef': secret_env, 'hasSecret': True, 'migrated': True}
|
|
legacy_row.updated_at = utc_now()
|
|
session.add(legacy_row)
|
|
log_event(
|
|
session,
|
|
source='system',
|
|
identity='secret_store',
|
|
action_id='secret_migrated',
|
|
target=secret_env,
|
|
target_type='secret',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason='Legacy plaintext connector secret migrated to encrypted secret store.',
|
|
result='success',
|
|
redacted_metadata={'connector': connector_id, 'secretRef': secret_env},
|
|
)
|
|
session.commit()
|
|
|
|
|
|
def _apply_connector_config_to_runtime(session: Session, connector_id: str) -> None:
|
|
_migrate_legacy_connector_secret(session, connector_id)
|
|
schema = _connector_schema(connector_id)
|
|
safe_config = _safe_config_from_row(session, connector_id)
|
|
for field, env_name in schema['field_env'].items():
|
|
if field in safe_config:
|
|
settings.apis[env_name] = safe_config[field]
|
|
if 'verifyTls' in safe_config and connector_id in {'portainer', 'unraid'}:
|
|
settings.verify_tls = bool(safe_config['verifyTls'])
|
|
secret_row = _get_setting(session, _setting_key(connector_id, 'secret'))
|
|
secret_env = schema.get('secret_env')
|
|
if secret_env:
|
|
stored_secret = get_secret(session, secret_env, touch=False)
|
|
if stored_secret:
|
|
settings.apis[secret_env] = stored_secret
|
|
elif secret_row and secret_row.value_json.get('value'):
|
|
settings.apis[secret_env] = secret_row.value_json.get('value')
|
|
|
|
|
|
def apply_stored_connector_settings(session: Session, connector_ids: set[str] | None = None) -> None:
|
|
selected = connector_ids if connector_ids is not None else set(_CONNECTOR_FIELDS)
|
|
for connector_id in _CONNECTOR_FIELDS:
|
|
if connector_id not in selected:
|
|
continue
|
|
_apply_connector_config_to_runtime(session, connector_id)
|
|
|
|
|
|
def serialize_connector_settings(session: Session, connector_id: str) -> dict[str, Any]:
|
|
_apply_connector_config_to_runtime(session, connector_id)
|
|
schema = _connector_schema(connector_id)
|
|
missing = [name for name in schema['required'] if not settings.apis.get(name)]
|
|
return {
|
|
'connectorId': connector_id,
|
|
'configured': not missing,
|
|
'hasSecret': _has_secret(session, connector_id),
|
|
'missing': missing,
|
|
'safeConfig': _safe_config_from_row(session, connector_id),
|
|
'secretRef': schema.get('secret_env'),
|
|
'secretStatus': _connector_secret_status(session, connector_id),
|
|
'secretStore': 'encrypted_local_db',
|
|
'secretStorage': secret_storage_status(),
|
|
}
|
|
|
|
|
|
def list_connector_settings(session: Session) -> list[dict[str, Any]]:
|
|
connector_ids = [connector.id for connector in session.exec(select(Connector).order_by(Connector.id)).all()]
|
|
for connector_id in _CONNECTOR_FIELDS:
|
|
if connector_id not in connector_ids:
|
|
connector_ids.append(connector_id)
|
|
return [serialize_connector_settings(session, connector_id) for connector_id in connector_ids]
|
|
|
|
|
|
def _normalize_connector_field(field: str, value: Any) -> Any:
|
|
if field == 'verifyTls':
|
|
return bool(value)
|
|
if not isinstance(value, str):
|
|
return value
|
|
normalized = value.strip()
|
|
if field in {'url', 'outpostUrl'}:
|
|
normalized = validate_connector_url(normalized)
|
|
return normalized
|
|
|
|
|
|
def save_connector_settings(session: Session, connector_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
schema = _connector_schema(connector_id)
|
|
safe_payload = dict(_safe_config_from_row(session, connector_id))
|
|
for field in schema['safe_fields']:
|
|
if field in payload and payload[field] is not None:
|
|
safe_payload[field] = _normalize_connector_field(field, payload[field])
|
|
if connector_id == 'portainer' and not str(safe_payload.get('endpointId') or '').strip():
|
|
safe_payload['endpointId'] = '1'
|
|
_set_setting(session, _setting_key(connector_id, 'config'), safe_payload, is_secret=False)
|
|
|
|
secret_value = payload.get('apiKey') or payload.get('token') or payload.get('password') or payload.get('secret')
|
|
if isinstance(secret_value, str) and secret_value.strip():
|
|
secret_env = schema.get('secret_env')
|
|
if secret_env:
|
|
set_secret(
|
|
session,
|
|
secret_env,
|
|
secret_value,
|
|
used_by=[connector_id],
|
|
metadata={'connectorId': connector_id},
|
|
commit=False,
|
|
)
|
|
_set_setting(session, _setting_key(connector_id, 'secret'), {'secretRef': secret_env, 'hasSecret': True}, is_secret=True)
|
|
|
|
session.commit()
|
|
_apply_connector_config_to_runtime(session, connector_id)
|
|
|
|
connector = session.get(Connector, connector_id)
|
|
if connector:
|
|
connector.status = 'disconnected' if serialize_connector_settings(session, connector_id)['configured'] else 'missing_config'
|
|
session.add(connector)
|
|
session.commit()
|
|
safety_center_service.invalidate_cache()
|
|
return serialize_connector_settings(session, connector_id)
|
|
|
|
|
|
def _step(step_id: str, label: str, status: str, required: bool) -> dict[str, Any]:
|
|
return {'id': step_id, 'label': label, 'status': status, 'required': required}
|
|
|
|
|
|
def _successful_portainer_test_exists(session: Session) -> bool:
|
|
event = session.exec(
|
|
select(AuditEvent)
|
|
.where(AuditEvent.action_id == 'setup.test.portainer')
|
|
.where(AuditEvent.result == 'success')
|
|
.order_by(AuditEvent.timestamp.desc())
|
|
).first()
|
|
return bool(event)
|
|
|
|
|
|
def _setup_flag(session: Session) -> tuple[bool, str | None]:
|
|
first_run = _get_setting(session, 'setup:first_run_completed')
|
|
completed_at = _get_setting(session, 'setup:completed_at')
|
|
return bool(first_run and first_run.value_json.get('value')), completed_at.value_json.get('value') if completed_at else None
|
|
|
|
|
|
def _set_setup_completed(session: Session, *, completed_at: str | None = None) -> str:
|
|
now = completed_at or utc_now().isoformat()
|
|
_set_setting(session, 'setup:first_run_completed', {'value': True})
|
|
_set_setting(session, 'setup:completed_at', {'value': now})
|
|
session.commit()
|
|
safety_center_service.invalidate_cache()
|
|
return now
|
|
|
|
|
|
def _required_blockers(
|
|
*,
|
|
portainer_ready: bool,
|
|
discovery_complete: bool,
|
|
managed_apps: int,
|
|
review_blockers: int,
|
|
secret_storage_ready: bool,
|
|
) -> list[str]:
|
|
blockers: list[str] = []
|
|
if not portainer_ready:
|
|
blockers.append('portainer_not_connected')
|
|
if not discovery_complete:
|
|
blockers.append('discovery_not_completed')
|
|
if managed_apps <= 0:
|
|
blockers.append('no_managed_apps')
|
|
if review_blockers > 0:
|
|
blockers.append('apps_need_review')
|
|
if not secret_storage_ready:
|
|
blockers.append('secret_storage_not_ready')
|
|
return blockers
|
|
|
|
|
|
def get_setup_status(session: Session) -> dict[str, Any]:
|
|
from .inventory_discovery import _latest_discovered_items
|
|
from .app_registry import list_active_apps
|
|
|
|
first_run_completed, setup_completed_at = _setup_flag(session)
|
|
portainer = serialize_connector_settings(session, 'portainer')
|
|
unraid = serialize_connector_settings(session, 'unraid')
|
|
portainer_row = session.get(Connector, 'portainer')
|
|
unraid_row = session.get(Connector, 'unraid')
|
|
portainer_connected = portainer['configured'] and portainer_row is not None and portainer_row.status == 'connected'
|
|
portainer_tested = _successful_portainer_test_exists(session)
|
|
unraid_ready = unraid['configured'] and (unraid_row is None or unraid_row.status == 'connected')
|
|
latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
|
|
# Setup is an operator-facing current-state summary. Discovery run history
|
|
# remains available under Inventory, but recreated Docker IDs must not make
|
|
# the setup counters grow forever.
|
|
discovered = _latest_discovered_items(session)
|
|
mappings = session.exec(select(AppContainerMapping)).all()
|
|
apps = list_active_apps(session)
|
|
connectors = list_connector_settings(session)
|
|
mapped_container_ids = {mapping.container_id for mapping in mappings}
|
|
apps_need_review = len([app for app in apps if app.needs_review]) + len([
|
|
item
|
|
for item in discovered
|
|
if item.import_status in {'discovered', 'unmapped'} or (item.import_status == 'changed' and item.container_id not in mapped_container_ids)
|
|
])
|
|
optional_connectors = [item for item in connectors if item['connectorId'] != 'portainer']
|
|
missing_optional_sources = [item['connectorId'] for item in optional_connectors if item['missing']]
|
|
missing_config = len([item for item in connectors if item['missing']])
|
|
mapped_apps = len(mappings)
|
|
managed_apps = len(apps)
|
|
review_blockers = 0 if first_run_completed else apps_need_review
|
|
discovery_status = 'pending'
|
|
if latest_run:
|
|
discovery_status = 'complete' if latest_run.status == 'success' else 'failed' if latest_run.status == 'failed' else 'warning'
|
|
discovery_complete = bool(latest_run and latest_run.status == 'success') or bool(discovered) or mapped_apps > 0
|
|
portainer_ready = portainer_connected or portainer_tested or discovery_complete
|
|
|
|
review_status = 'pending' if not latest_run else ('complete' if review_blockers == 0 else 'warning')
|
|
connectors_status = 'warning' if missing_optional_sources else 'complete'
|
|
home_assistant_configured = 'home_assistant' not in missing_optional_sources
|
|
voice_status = 'complete' if settings.voice_token and home_assistant_configured else 'warning'
|
|
storage_status = secret_storage_status()
|
|
secret_storage_ready = storage_status['status'] == 'ready'
|
|
required_blockers = _required_blockers(
|
|
portainer_ready=portainer_ready,
|
|
discovery_complete=discovery_complete,
|
|
managed_apps=mapped_apps,
|
|
review_blockers=review_blockers,
|
|
secret_storage_ready=secret_storage_ready,
|
|
)
|
|
required_steps_complete = not required_blockers
|
|
setup_complete = required_steps_complete and first_run_completed
|
|
coverage_complete = not missing_optional_sources
|
|
live_ready = setup_complete and settings.port == 1216
|
|
warnings: list[str] = []
|
|
if missing_optional_sources:
|
|
warnings.append('Optional connector coverage is partial.')
|
|
if settings.port != 1216:
|
|
warnings.append('Runtime is not on canonical production port 1216.')
|
|
next_actions = [
|
|
{'id': blocker, 'required': True}
|
|
for blocker in required_blockers
|
|
]
|
|
if required_steps_complete and not first_run_completed:
|
|
next_actions.append({'id': 'reconcile_setup_state', 'required': True})
|
|
for connector_id in missing_optional_sources:
|
|
next_actions.append({'id': f'configure_optional_{connector_id}', 'required': False})
|
|
|
|
steps = [
|
|
_step('portainer', 'Portainer verbinden', 'complete' if portainer_ready else 'pending', True),
|
|
_step('unraid', 'Unraid API verbinden', 'complete' if unraid_ready else 'warning', False),
|
|
_step('discovery', 'Container discovery draaien', 'complete' if discovery_complete else discovery_status, True),
|
|
_step('review', 'Reviewbaseline vastgelegd' if first_run_completed else 'Apps reviewen', review_status, True),
|
|
_step('connectors', 'Connectoren binden', connectors_status, False),
|
|
_step('voice', 'Voice/Home Assistant instellen', voice_status, False),
|
|
]
|
|
return {
|
|
'firstRunCompleted': first_run_completed,
|
|
'setupComplete': setup_complete,
|
|
'liveReady': live_ready,
|
|
'coverageComplete': coverage_complete,
|
|
'securityCoveragePartial': not coverage_complete,
|
|
'setupCompletedAt': setup_completed_at,
|
|
'requiredStepsComplete': required_steps_complete,
|
|
'optionalStepsComplete': coverage_complete,
|
|
'reviewBlockers': review_blockers,
|
|
'managedApps': managed_apps,
|
|
'needsReview': apps_need_review,
|
|
'missingOptionalSources': missing_optional_sources,
|
|
'steps': steps,
|
|
'warnings': warnings,
|
|
'nextActions': next_actions,
|
|
'blockers': required_blockers,
|
|
'summary': {
|
|
'containersDiscovered': len({item.container_id for item in discovered}),
|
|
'appsManaged': managed_apps,
|
|
'appsNeedReview': apps_need_review,
|
|
'connectorsMissingConfig': missing_config,
|
|
'missingOptionalSources': missing_optional_sources,
|
|
},
|
|
}
|
|
|
|
|
|
def complete_setup(session: Session) -> dict[str, Any]:
|
|
from .inventory_discovery import sync_app_connector_statuses
|
|
|
|
sync_app_connector_statuses(session)
|
|
_set_setup_completed(session)
|
|
return {'success': True, 'message': 'setup_completed', 'status': get_setup_status(session)}
|
|
|
|
|
|
def reconcile_setup(session: Session) -> dict[str, Any]:
|
|
from .inventory_discovery import sync_app_connector_statuses
|
|
|
|
sync_app_connector_statuses(session)
|
|
before = get_setup_status(session)
|
|
blockers = list(before.get('blockers') or [])
|
|
if blockers:
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='setup',
|
|
action_id='setup.reconcile',
|
|
target='setup',
|
|
target_type='setup',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason='Setup reconciliation blocked by required criteria.',
|
|
result='blocked',
|
|
before_state={'firstRunCompleted': before['firstRunCompleted'], 'blockers': blockers},
|
|
after_state={'firstRunCompleted': before['firstRunCompleted']},
|
|
redacted_metadata={'blockers': blockers},
|
|
)
|
|
after = get_setup_status(session)
|
|
return {'success': False, 'message': 'setup_reconcile_blocked', 'blockers': blockers, 'before': before, 'after': after}
|
|
|
|
if not before['firstRunCompleted']:
|
|
_set_setup_completed(session)
|
|
after = get_setup_status(session)
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='setup',
|
|
action_id='setup.reconcile',
|
|
target='setup',
|
|
target_type='setup',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason='Setup state reconciled from existing live data.',
|
|
result='success',
|
|
before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']},
|
|
after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']},
|
|
redacted_metadata={'managedApps': after['managedApps'], 'coverageComplete': after['coverageComplete']},
|
|
)
|
|
return {'success': True, 'message': 'setup_reconciled', 'blockers': [], 'before': before, 'after': after}
|
|
|
|
|
|
def mark_setup_complete(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
|
if payload.get('confirm') != 'MARK_SETUP_COMPLETE':
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status_code=400, detail='confirm_required')
|
|
before = get_setup_status(session)
|
|
_set_setup_completed(session)
|
|
after = get_setup_status(session)
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='setup',
|
|
action_id='setup.mark_complete',
|
|
target='setup',
|
|
target_type='setup',
|
|
risk='medium',
|
|
policy_decision='allowed',
|
|
reason='Operator explicitly marked setup complete.',
|
|
result='success',
|
|
before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']},
|
|
after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']},
|
|
redacted_metadata={'coverageComplete': after['coverageComplete'], 'blockers': after.get('blockers', [])},
|
|
)
|
|
return {'success': True, 'message': 'setup_marked_complete', 'status': after}
|
|
|
|
|
|
def mark_voice_configured(session: Session) -> dict[str, Any]:
|
|
_set_setting(session, 'setup:voice_configured', {'value': True})
|
|
session.commit()
|
|
return {'success': True, 'message': 'voice_configured', 'status': get_setup_status(session)}
|
|
|
|
|
|
def test_portainer_setup(session: Session) -> dict[str, Any]:
|
|
config = serialize_connector_settings(session, 'portainer')
|
|
if config['missing']:
|
|
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'}
|
|
connector = PortainerConnector()
|
|
result = connector.test_connection()
|
|
status = str(result.get('status', 'error'))
|
|
if status != 'connected':
|
|
message = redact_message(str(result.get('reason', status)))
|
|
log_setup_test(session, 'portainer', status, message)
|
|
response_status = 'unreachable' if status == 'error' else status
|
|
required_fields = ['PORTAINER_ENDPOINT_ID'] if response_status == 'endpoint_error' else None
|
|
return {
|
|
'success': False,
|
|
'status': response_status,
|
|
'connected': False,
|
|
'message': message,
|
|
'requiredFields': required_fields,
|
|
'safeConfig': config['safeConfig'],
|
|
}
|
|
containers = connector.list_containers()
|
|
stacks = connector.list_stacks()
|
|
if containers.get('status') != 'ok':
|
|
message = redact_message(str(containers.get('reason', 'endpoint_error')))
|
|
log_setup_test(session, 'portainer', 'endpoint_error', message)
|
|
return {
|
|
'success': False,
|
|
'status': 'endpoint_error',
|
|
'connected': False,
|
|
'message': message,
|
|
'requiredFields': ['PORTAINER_ENDPOINT_ID'],
|
|
'summary': {'containers': 0, 'stacks': 0, 'endpointReachable': False},
|
|
'safeConfig': config['safeConfig'],
|
|
}
|
|
payload = {
|
|
'success': True,
|
|
'status': 'connected',
|
|
'connected': True,
|
|
'summary': {
|
|
'containers': len(containers.get('containers', [])),
|
|
'stacks': len(stacks.get('stacks', [])) if stacks.get('status') == 'ok' else 0,
|
|
'endpointReachable': True,
|
|
},
|
|
'safeConfig': config['safeConfig'],
|
|
}
|
|
connector_row = session.get(Connector, 'portainer')
|
|
if connector_row:
|
|
connector_row.status = 'connected'
|
|
session.add(connector_row)
|
|
session.commit()
|
|
log_setup_test(session, 'portainer', 'connected', 'connected')
|
|
return payload
|
|
|
|
|
|
def test_unraid_setup(session: Session) -> dict[str, Any]:
|
|
config = serialize_connector_settings(session, 'unraid')
|
|
if config['missing']:
|
|
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'}
|
|
connector = UnraidConnector()
|
|
result = connector.test_connection()
|
|
status = str(result.get('status', 'error'))
|
|
if status != 'connected':
|
|
message = redact_message(str(result.get('reason', status)))
|
|
log_setup_test(session, 'unraid', status, message)
|
|
return {'success': False, 'status': 'unreachable' if status == 'error' else status, 'connected': False, 'message': message}
|
|
info = connector.get_server_info()
|
|
array = connector.get_array_status()
|
|
docker = connector.get_docker_service_status()
|
|
payload = {
|
|
'success': True,
|
|
'status': 'connected',
|
|
'connected': True,
|
|
'summary': {
|
|
'server': info.get('info', {}),
|
|
'arrayStatus': array.get('arrayStatus'),
|
|
'dockerService': docker.get('serviceStatus'),
|
|
},
|
|
'safeConfig': config['safeConfig'],
|
|
}
|
|
connector_row = session.get(Connector, 'unraid')
|
|
if connector_row:
|
|
connector_row.status = 'connected'
|
|
session.add(connector_row)
|
|
session.commit()
|
|
log_setup_test(session, 'unraid', 'connected', 'connected')
|
|
return payload
|
|
|
|
|
|
def log_setup_test(session: Session, connector_id: str, result: str, reason: str) -> None:
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='setup',
|
|
action_id=f'setup.test.{connector_id}',
|
|
target=connector_id,
|
|
target_type='connector',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason=redact_message(reason),
|
|
result='success' if result == 'connected' else 'failed',
|
|
redacted_metadata={'connector': connector_id, 'result': result},
|
|
)
|