Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+944
View File
@@ -0,0 +1,944 @@
from datetime import UTC, datetime
from sqlalchemy import inspect, text
from sqlmodel import SQLModel, select
from . import session as db_session
from .session import get_session
from ..core.config import Settings
from ..services.app_classifier import classify_container
from ..services.app_registry import category_label
from .models import App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion
settings = Settings()
def init_db() -> None:
SQLModel.metadata.create_all(db_session.engine)
_apply_lightweight_schema_upgrades()
with next(get_session()) as session:
changed = False
for app in _seed_apps():
if session.get(App, app.id) is None:
session.add(app)
changed = True
for connector in _seed_connectors():
if session.get(Connector, connector.id) is None:
session.add(connector)
changed = True
for policy in _seed_policies():
if session.get(Policy, policy.id) is None:
session.add(policy)
changed = True
for channel in _seed_notification_channels():
if session.get(NotificationChannel, channel.id) is None:
session.add(channel)
changed = True
for rule in _seed_notification_rules():
if session.get(NotificationRule, rule.id) is None:
session.add(rule)
changed = True
changed = _upgrade_unknown_app_classifications(session) or changed
changed = _upgrade_default_notification_rule_channels(session) or changed
changed = _upgrade_duplicate_notification_events(session) or changed
changed = _upgrade_suppressed_notification_events(session) or changed
if changed:
session.commit()
from ..services.setup_service import apply_stored_connector_settings
from ..services.runtime_service import apply_stored_operator_auth_setting
apply_stored_connector_settings(session)
apply_stored_operator_auth_setting(session)
_ensure_notification_suppression_index()
def _apply_lightweight_schema_upgrades() -> None:
inspector = inspect(db_session.engine)
table_names = set(inspector.get_table_names())
with db_session.engine.begin() as connection:
if 'notification_channels' in table_names:
columns = {column['name'] for column in inspector.get_columns('notification_channels')}
if 'last_test_at' not in columns:
connection.execute(text('ALTER TABLE notification_channels ADD COLUMN last_test_at DATETIME'))
if 'approval' in table_names:
columns = {column['name'] for column in inspector.get_columns('approval')}
approval_columns = {
'decided_at': 'DATETIME',
'executed_at': 'DATETIME',
'dismissed_at': 'DATETIME',
'job_id': 'VARCHAR',
'audit_event_id': 'VARCHAR',
'approved_by': 'VARCHAR',
'executed_by': 'VARCHAR',
'action_fingerprint': 'VARCHAR',
'request_context_json': "JSON DEFAULT '{}'",
}
for name, sql_type in approval_columns.items():
if name not in columns:
connection.execute(text(f'ALTER TABLE approval ADD COLUMN {name} {sql_type}'))
if 'policy' in table_names:
policy_columns = {column['name'] for column in inspector.get_columns('policy')}
if 'provenance_json' not in policy_columns:
connection.execute(text("ALTER TABLE policy ADD COLUMN provenance_json JSON DEFAULT '{}'"))
if 'discovery_runs' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovery_runs_started_at ON discovery_runs (started_at)'))
if 'discovered_containers' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_discovery_run_id ON discovered_containers (discovery_run_id)'))
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_container_id ON discovered_containers (container_id)'))
if 'app_container_mappings' in table_names:
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_app_id ON app_container_mappings (app_id)'))
connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_container_id ON app_container_mappings (container_id)'))
def _upgrade_unknown_app_classifications(session) -> bool:
changed = False
risk_rank = {'low': 0, 'medium': 1, 'high': 2, 'critical': 3}
unknown_apps = [
app
for app in session.exec(select(App)).all()
if str(app.category or '').strip().lower() in {'', 'unknown', 'generic'}
]
if not unknown_apps:
return False
mappings_by_app: dict[str, list[AppContainerMapping]] = {}
for mapping in session.exec(select(AppContainerMapping)).all():
mappings_by_app.setdefault(mapping.app_id, []).append(mapping)
discovered_by_container: dict[str, list[DiscoveredContainer]] = {}
for item in session.exec(select(DiscoveredContainer)).all():
discovered_by_container.setdefault(item.container_id, []).append(item)
for app in unknown_apps:
mappings = mappings_by_app.get(app.id, [])
evidence = [
item
for mapping in mappings
for item in discovered_by_container.get(mapping.container_id, [])
]
classification = classify_container({
'name': ' '.join([app.id, app.name, *(mapping.container_name for mapping in mappings)]),
'image': ' '.join(item.image for item in evidence),
'ports': [port for item in evidence for port in (item.ports_json or [])],
})
if classification.get('app_type') == 'unknown' or float(classification.get('confidence', 0)) < 0.8:
continue
app.category = category_label(str(classification['category']))
suggested_criticality = str(classification.get('criticality') or 'low')
if risk_rank.get(suggested_criticality, 0) > risk_rank.get(app.criticality, 0):
app.criticality = suggested_criticality
app.protected = app.protected or bool(classification.get('protected'))
session.add(app)
changed = True
return changed
def _upgrade_duplicate_notification_events(session) -> bool:
rules = {rule.id: rule for rule in session.exec(select(NotificationRule)).all()}
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.status != 'dismissed')
.order_by(NotificationEvent.created_at.desc())
).all()
grouped: dict[tuple[str, ...], list[NotificationEvent]] = {}
for row in rows:
key = (row.source, row.event_type, row.dedupe_key, row.title, row.message)
grouped.setdefault(key, []).append(row)
changed = False
severity_rank = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4}
def specificity(row: NotificationEvent) -> tuple[int, object]:
rule = rules.get(row.rule_id)
if not rule:
return 0, row.created_at
conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {}
constrained = sum((bool(rule.categories_json), bool(rule.targets_json), bool(conditions)))
return constrained * 100 + severity_rank.get(rule.severity_min, 0), row.created_at
specific_media_rows = [
row for row in rows
if row.rule_id in {'notify-deluge-stalled-downloads', 'notify-widefrog-failed-jobs'}
]
for row in rows:
if row.rule_id != 'notify-media-pipeline-issue':
continue
message = str(row.message or '').lower()
expected_rule = (
'notify-deluge-stalled-downloads'
if 'deluge' in message and 'stalled' in message
else 'notify-widefrog-failed-jobs'
if 'widefrog' in message and 'fail' in message
else None
)
if not expected_rule:
continue
overlap = next((
specific for specific in specific_media_rows
if specific.rule_id == expected_rule
and abs((specific.created_at - row.created_at).total_seconds()) <= 5
), None)
if not overlap:
continue
row.status = 'dismissed'
row.reason = 'superseded_by_specific_rule'
row.read = True
session.add(row)
changed = True
for duplicates in grouped.values():
if len(duplicates) < 2:
continue
timestamps = [row.created_at for row in duplicates]
if (max(timestamps) - min(timestamps)).total_seconds() > 5:
continue
keep = max(duplicates, key=specificity)
for row in duplicates:
if row.id == keep.id:
continue
row.status = 'dismissed'
row.reason = 'superseded_by_specific_rule'
row.read = True
session.add(row)
changed = True
return changed
def _upgrade_suppressed_notification_events(session) -> bool:
"""Collapse legacy scheduler spam while retaining one read suppression record per identity."""
def suppression_count(row: NotificationEvent) -> int:
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
try:
return max(1, int(payload.get('suppressionCount') or 1))
except (TypeError, ValueError):
return 1
def suppression_timestamp(row: NotificationEvent, key: str) -> datetime:
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
raw_value = payload.get(key)
if isinstance(raw_value, str):
try:
parsed = datetime.fromisoformat(raw_value.replace('Z', '+00:00'))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
except ValueError:
pass
return row.created_at
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.status == 'suppressed')
.order_by(NotificationEvent.created_at.desc())
).all()
grouped: dict[tuple[str, str, str], list[NotificationEvent]] = {}
changed = False
for row in rows:
if not row.read:
row.read = True
session.add(row)
changed = True
if row.reason not in {'cooldown', 'quiet_hours'}:
continue
key = (row.rule_id, row.dedupe_key, row.reason or 'unspecified')
grouped.setdefault(key, []).append(row)
for duplicates in grouped.values():
keeper = duplicates[0]
payload = keeper.payload_json if isinstance(keeper.payload_json, dict) else {}
total = sum(suppression_count(row) for row in duplicates)
first_suppressed = min(suppression_timestamp(row, 'firstSuppressedAt') for row in duplicates)
last_suppressed = max(suppression_timestamp(row, 'lastSuppressedAt') for row in duplicates)
normalized_payload = {
**payload,
'suppressionCount': total,
'firstSuppressedAt': first_suppressed.isoformat(),
'lastSuppressedAt': last_suppressed.isoformat(),
}
if not keeper.read or keeper.payload_json != normalized_payload:
keeper.read = True
keeper.payload_json = normalized_payload
session.add(keeper)
changed = True
for duplicate in duplicates[1:]:
session.delete(duplicate)
changed = True
return changed
def _ensure_notification_suppression_index() -> None:
"""Enforce one live routine-suppression row per rule identity after legacy compaction."""
with db_session.engine.begin() as connection:
connection.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_routine_suppression
ON notification_events (rule_id, dedupe_key, reason)
WHERE status = 'suppressed' AND reason IN ('cooldown', 'quiet_hours')
"""))
def _seed_apps() -> list[App]:
return [
App(
id="widefrog",
name="Widefrog",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="sonarr",
name="Sonarr",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="radarr",
name="Radarr",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="prowlarr",
name="Prowlarr",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="deluge",
name="Deluge",
category="Downloads",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="plex",
name="Plex",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="tautulli",
name="Tautulli",
category="Media",
status="healthy",
criticality="medium",
connector_status="connected" if (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="jellyfin",
name="Jellyfin",
category="Media",
status="healthy",
criticality="low",
connector_status="connected",
container_status="running",
voice_enabled=True,
),
App(
id="gitea",
name="Gitea",
category="Dev",
status="healthy",
criticality="medium",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="projectbrain",
name="ProjectBrain",
category="Dev",
status="healthy",
criticality="medium",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="psforge",
name="PSForge",
category="Dev",
status="healthy",
criticality="low",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="spoortracker",
name="SpoorTracker",
category="Monitoring",
status="healthy",
criticality="low",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
),
App(
id="authentik",
name="Authentik",
category="Auth",
status="healthy",
criticality="critical",
connector_status="connected" if (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="nginx_proxy_manager",
name="Nginx Proxy Manager",
category="Network",
status="healthy",
criticality="critical",
connector_status="connected" if (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="zabbix",
name="Zabbix",
category="Monitoring",
status="healthy",
criticality="high",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="home_assistant",
name="Home Assistant",
category="Infrastructure",
status="healthy",
criticality="medium",
connector_status="connected" if settings.apis.get("HOME_ASSISTANT_URL") else "disconnected",
container_status="running",
voice_enabled=True,
),
App(
id="portainer",
name="Portainer",
category="Infrastructure",
status="healthy",
criticality="high",
connector_status="connected" if settings.apis.get("PORTAINER_URL") else "disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="postgres",
name="PostgreSQL",
category="Database",
status="healthy",
criticality="critical",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
App(
id="redis",
name="Redis",
category="Database",
status="healthy",
criticality="high",
connector_status="disconnected",
container_status="running",
voice_enabled=False,
protected=True,
),
]
def _seed_notification_channels() -> list[NotificationChannel]:
return [
NotificationChannel(
id="dashboard",
type="dashboard",
name="Dashboard",
enabled=True,
config_json={"delivery": "in_app"},
secret_refs_json=[],
),
NotificationChannel(
id="home_assistant",
type="home_assistant",
name="Home Assistant",
enabled=False,
config_json={
"url": settings.apis.get("HOME_ASSISTANT_URL"),
"notifyDomain": settings.apis.get("HOME_ASSISTANT_NOTIFY_DOMAIN") or "notify",
"notifyService": settings.apis.get("HOME_ASSISTANT_NOTIFY_SERVICE") or "notify",
"target": settings.apis.get("HOME_ASSISTANT_NOTIFY_TARGET"),
"verifyTls": bool(settings.apis.get("HOME_ASSISTANT_VERIFY_TLS")),
"timeoutSeconds": int(settings.apis.get("HOME_ASSISTANT_TIMEOUT_SECONDS") or 10),
"titlePrefix": settings.apis.get("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX") or "AppOps",
"dryRun": bool(settings.apis.get("HOME_ASSISTANT_DRY_RUN")),
},
secret_refs_json=["HOME_ASSISTANT_TOKEN"],
),
NotificationChannel(id="discord", type="discord", name="Discord", enabled=False, config_json={"username": "AppOps", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_DISCORD_WEBHOOK"]),
NotificationChannel(id="telegram", type="telegram", name="Telegram", enabled=False, config_json={"chatId": "", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_TELEGRAM_BOT_TOKEN"]),
NotificationChannel(id="email", type="email", name="E-mail", enabled=False, config_json={"host": "", "port": 465, "username": "", "fromAddress": "", "toAddress": "", "useTls": True, "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_SMTP_PASSWORD"]),
]
def _upgrade_default_notification_rule_channels(session) -> bool:
home_assistant_defaults = {
"notify-critical-safety-finding",
"notify-high-safety-finding",
"notify-npm-certificate-expiring",
"notify-public-host-without-auth",
"notify-blocked-dangerous-action",
}
changed = False
for rule_id in home_assistant_defaults:
rule = session.get(NotificationRule, rule_id)
if rule and "home_assistant" not in (rule.channels_json or []):
rule.channels_json = [*(rule.channels_json or ["dashboard"]), "home_assistant"]
changed = True
session.add(rule)
return changed
def _seed_notification_rules() -> list[NotificationRule]:
return [
NotificationRule(id="notify-critical-safety-finding", name="Critical Safety Finding", source="safety", event_type="safety.finding", severity_min="critical", cooldown_seconds=900, quiet_hours_json={"enabled": False, "allowCriticalOverride": True}, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-high-safety-finding", name="High Safety Finding", source="safety", event_type="safety.finding", severity_min="high", cooldown_seconds=3600, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-coverage-incomplete", name="Coverage Incomplete", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["coverage", "connector_config", "setup", "connectors"], cooldown_seconds=86400, channels_json=["dashboard"]),
NotificationRule(id="notify-connector-missing-config", name="Connector Missing Config", source="connector", event_type="connector.missing_config", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]),
NotificationRule(id="notify-connector-failed", name="Connector Failure After Previously Connected", source="connector", event_type="connector.failed", severity_min="medium", cooldown_seconds=1800, channels_json=["dashboard"]),
NotificationRule(id="notify-media-pipeline-issue", name="Media Pipeline Issue", source="media_pipeline", event_type="media.issue", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-deluge-stalled-downloads", name="Deluge Stalled Downloads", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["downloads"], cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-widefrog-failed-jobs", name="Widefrog Failed Jobs", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["widefrog"], cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-npm-certificate-expiring", name="NPM Certificate Expiring", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["exposure"], cooldown_seconds=86400, channels_json=["dashboard", "home_assistant"], conditions_json={"titleContains": ["certificate", "certificaat"]}),
NotificationRule(id="notify-public-host-without-auth", name="Public Host Without Auth Coverage", source="safety", event_type="safety.finding", severity_min="high", categories_json=["auth", "exposure"], cooldown_seconds=43200, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-blocked-dangerous-action", name="Blocked Dangerous Action Attempt", source="audit", event_type="audit.blocked_action", severity_min="high", cooldown_seconds=1800, channels_json=["dashboard", "home_assistant"]),
NotificationRule(id="notify-voice-auth-failure", name="Voice Auth Failure", source="audit", event_type="audit.voice_denied", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]),
NotificationRule(id="notify-discovery-needs-review", name="Discovery Needs Review", source="inventory", event_type="inventory.needs_review", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]),
]
def _seed_voice_export_policies() -> list[Policy]:
return [
Policy(id="policy-unraid-health-summary", action="unraid.health_summary", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading Unraid health, storage and VM inventory is read-only; mover, format and power mutations use separate actions."),
Policy(id="policy-unraid-vms", action="unraid.vms", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading the Unraid VM inventory is read-only."),
Policy(id="policy-unraid-vm-start", action="unraid.vm.start", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Starting a VM changes host load; manual execution is confirmed and audited, while voice requires approval."),
Policy(id="policy-unraid-vm-resume", action="unraid.vm.resume", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a paused VM changes host load; manual execution is confirmed and audited, while voice requires approval."),
Policy(id="policy-unraid-vm-pause", action="unraid.vm.pause", target="*", voice="blocked", manual="approval_required", risk="high", reason="Pausing a running VM interrupts its workload and requires explicit operator approval."),
Policy(id="policy-unraid-vm-stop", action="unraid.vm.stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Unraid stop can force-stop after timeout and remains blocked."),
Policy(id="policy-unraid-vm-reboot", action="unraid.vm.reboot", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reboot remains blocked until shutdown and recovery semantics are safely verifiable."),
Policy(id="policy-unraid-vm-force-stop", action="unraid.vm.force_stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Force-stop is destructive and blocked."),
Policy(id="policy-unraid-vm-reset", action="unraid.vm.reset", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reset is destructive and blocked."),
Policy(id="policy-setup-status", action="setup.status", target="appops", voice="allowed", manual="allowed", risk="low", reason="Reading AppOps setup status is read-only."),
Policy(id="policy-safety-summary", action="safety.summary", target="safety", voice="allowed", manual="allowed", risk="low", reason="Reading Safety Center summary is read-only."),
Policy(id="policy-media-pipeline-check", action="media_pipeline.check", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline voice check is read-only in the voice export path."),
Policy(id="policy-media-pipeline-diagnose", action="media_pipeline.diagnose", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline diagnosis is a read-only evidence summary and does not execute connector actions."),
Policy(id="policy-notifications-summary", action="notifications.summary", target="notifications", voice="allowed", manual="allowed", risk="low", reason="Reading notification summary is read-only and never returns notification tokens."),
Policy(id="policy-notifications-evaluate-safety", action="notifications.evaluate_safety", target="notifications", voice="approval_required", manual="allowed", risk="medium", reason="Evaluating safety notifications can create notification events and requires voice approval."),
]
def _seed_connectors() -> list[Connector]:
return [
Connector(
id="portainer",
name="Portainer",
status="disconnected" if not settings.apis.get("PORTAINER_URL") else "connected",
last_test="never",
secret_ref="PORTAINER_API_KEY",
capabilities=[
"container.status",
"container.logs",
"container.restart",
"container.stop",
"stack.status",
"stack.redeploy",
],
type="Infrastructure",
),
Connector(
id="unraid",
name="Unraid",
status="disconnected" if not settings.apis.get("UNRAID_URL") else "connected",
last_test="never",
secret_ref="UNRAID_API_KEY",
capabilities=[
"unraid.health_summary",
"unraid.array",
"unraid.disks",
"unraid.cache",
"unraid.parity",
"unraid.docker",
"unraid.vms",
"unraid.shares",
],
type="Infrastructure",
),
Connector(
id="sonarr",
name="Sonarr",
status="disconnected" if not settings.apis.get("SONARR_URL") else "connected",
last_test="never",
secret_ref="SONARR_API_KEY",
capabilities=[
"sonarr.health",
"sonarr.search_series",
"sonarr.add_series",
"sonarr.queue_status",
"sonarr.wanted_missing",
],
type="Media",
),
Connector(
id="radarr",
name="Radarr",
status="disconnected" if not (settings.apis.get("RADARR_URL") and settings.apis.get("RADARR_API_KEY")) else "connected",
last_test="never",
secret_ref="RADARR_API_KEY",
capabilities=[
"radarr.health",
"radarr.search_movie",
"radarr.add_movie",
"radarr.queue_status",
"radarr.wanted_missing",
],
type="Media",
),
Connector(
id="prowlarr",
name="Prowlarr",
status="disconnected" if not (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "connected",
last_test="never",
secret_ref="PROWLARR_API_KEY",
capabilities=[
"prowlarr.health",
"prowlarr.indexer_status",
"prowlarr.application_status",
"prowlarr.indexer_health",
"prowlarr.search_test",
"prowlarr.sync_status",
],
type="Media",
),
Connector(
id="deluge",
name="Deluge",
status="disconnected" if not (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "connected",
last_test="never",
secret_ref="DELUGE_PASSWORD",
capabilities=[
"deluge.health",
"deluge.session_status",
"deluge.torrent_status",
"deluge.active_downloads",
"deluge.stalled_downloads",
"deluge.paused_downloads",
"deluge.speed_status",
"deluge.pause_torrent",
"deluge.resume_torrent",
"deluge.pause_all",
"deluge.resume_all",
],
type="Downloads",
),
Connector(
id="plex",
name="Plex",
status="disconnected" if not (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "connected",
last_test="never",
secret_ref="PLEX_TOKEN",
capabilities=[
"plex.health",
"plex.server_status",
"plex.library_status",
"plex.active_sessions",
"plex.recent_media",
"plex.scan_library",
"plex.scan_all_libraries",
"plex.stream_summary",
],
type="Media",
),
Connector(
id="tautulli",
name="Tautulli",
status="disconnected" if not (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "connected",
last_test="never",
secret_ref="TAUTULLI_API_KEY",
capabilities=[
"tautulli.health",
"tautulli.status",
"tautulli.activity",
"tautulli.history",
"tautulli.users",
"tautulli.libraries",
"tautulli.recently_added",
"tautulli.watch_stats",
"tautulli.stream_summary",
"tautulli.plex_info",
],
type="Media",
),
Connector(
id="authentik",
name="Authentik",
status="disconnected" if not (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "connected",
last_test="never",
secret_ref="AUTHENTIK_TOKEN",
capabilities=[
"authentik.health",
"authentik.system_status",
"authentik.applications",
"authentik.providers",
"authentik.proxy_providers",
"authentik.oauth2_providers",
"authentik.outposts",
"authentik.outpost_health",
"authentik.flows_summary",
"authentik.policies_summary",
"authentik.events",
"authentik.auth_coverage",
"authentik.security_warnings",
],
type="Auth",
),
Connector(
id="nginx_proxy_manager",
name="Nginx Proxy Manager",
status="disconnected" if not (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "connected",
last_test="never",
secret_ref="NPM_SECRET",
capabilities=[
"npm.health",
"npm.proxy_hosts",
"npm.certificates",
"npm.expiring_certificates",
"npm.access_lists",
"npm.exposure_summary",
"npm.host_reachability",
"npm.security_warnings",
],
type="Network",
),
Connector(
id="widefrog",
name="Widefrog",
status="disconnected" if not settings.apis.get("WIDEFROG_URL") else "connected",
last_test="never",
secret_ref="WIDEFROG_API_KEY",
capabilities=[
"widefrog.health",
"widefrog.queue_status",
"widefrog.failed_jobs",
"widefrog.activity",
"widefrog.retry_failed",
"widefrog.scan_library",
"widefrog.download_status",
],
type="Media",
),
Connector(
id="home_assistant",
name="Home Assistant",
status="disconnected" if not settings.apis.get("HOME_ASSISTANT_URL") else "connected",
last_test="never",
secret_ref="HOME_ASSISTANT_TOKEN",
capabilities=["read_status", "trigger_action"],
type="Automation",
),
]
def _seed_policies() -> list[Policy]:
return [
Policy(id="policy-volume-delete", action="volume.delete", target="*", voice="blocked", manual="blocked", risk="critical", reason="Dangerous operation."),
Policy(id="policy-container-stop", action="container.stop", target="*", voice="blocked", manual="blocked", risk="high", reason="Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review."),
Policy(id="policy-container-clear-logs", action="container.clear_logs", target="*", voice="blocked", manual="blocked", risk="high", reason="Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only."),
Policy(id="policy-gateway-clear-cache", action="gateway.clear_cache", target="gateway", voice="blocked", manual="blocked", risk="high", reason="Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review."),
Policy(id="policy-container-restart-authentik", action="container.restart", target="authentik", voice="approval_required", manual="approval_required", risk="critical", reason="Authenticator restart is high-risk."),
Policy(id="policy-container-restart-widefrog", action="container.restart", target="widefrog", voice="allowed", manual="allowed", risk="medium", reason="Widefrog restart is a safe first-line media action."),
Policy(id="policy-unraid-mover", action="unraid.mover.start", target="unraid", voice="blocked", manual="approval_required", risk="high", reason="Mover start requires manual approval except read-only voice flows."),
Policy(id="policy-stack-redeploy", action="stack.redeploy", target="*", voice="approval_required", manual="approval_required", risk="high", reason="Stack redeploy needs approval in this safe policy."),
*_seed_voice_export_policies(),
Policy(id="policy-sonarr-search", action="sonarr.search_series", target="sonarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Sonarr lookup."),
Policy(id="policy-sonarr-add", action="sonarr.add_series", target="sonarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored series is a reversible app-level action and does not affect infrastructure."),
Policy(id="policy-sonarr-delete", action="sonarr.delete_series", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting series can remove metadata/files and is not supported by AppOps Gateway V1."),
Policy(id="policy-sonarr-mass-edit", action="sonarr.mass_edit", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-radarr-search", action="radarr.search_movie", target="radarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Radarr lookup."),
Policy(id="policy-radarr-add", action="radarr.add_movie", target="radarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored movie is a reversible app-level action and does not affect infrastructure."),
Policy(id="policy-radarr-delete", action="radarr.delete_movie", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movies can remove metadata/files and is not supported by AppOps Gateway V1."),
Policy(id="policy-radarr-delete-files", action="radarr.delete_files", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movie files is destructive and is blocked by AppOps Gateway V1."),
Policy(id="policy-radarr-mass-edit", action="radarr.mass_edit", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-widefrog-health", action="widefrog.health", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog health check."),
Policy(id="policy-widefrog-queue", action="widefrog.queue_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog queue status."),
Policy(id="policy-widefrog-failed", action="widefrog.failed_jobs", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog failed jobs status."),
Policy(id="policy-widefrog-activity", action="widefrog.activity", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog activity status."),
Policy(id="policy-widefrog-download-status", action="widefrog.download_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog download status."),
Policy(id="policy-widefrog-scan-library", action="widefrog.scan_library", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Scanning the media library is a non-destructive app-level action but may create load."),
Policy(id="policy-widefrog-retry-failed", action="widefrog.retry_failed", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Retrying failed jobs may start downloads or processing and should be operator-controlled."),
Policy(id="policy-widefrog-delete-job", action="widefrog.delete_job", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting Widefrog jobs is not supported by AppOps Gateway V1."),
Policy(id="policy-widefrog-delete-media", action="widefrog.delete_media", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting media is blocked."),
Policy(id="policy-widefrog-clear-library", action="widefrog.clear_library", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing library data is blocked."),
Policy(id="policy-widefrog-clear-queue", action="widefrog.clear_queue", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing queue data is blocked."),
Policy(id="policy-widefrog-reset-database", action="widefrog.reset_database", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Database reset is blocked."),
Policy(id="policy-widefrog-delete-downloads", action="widefrog.delete_downloads", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloads is blocked."),
Policy(id="policy-widefrog-prune", action="widefrog.prune", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Prune actions are blocked."),
Policy(id="policy-prowlarr-health", action="prowlarr.health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr health is read-only."),
Policy(id="policy-prowlarr-indexers", action="prowlarr.indexer_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer status is read-only."),
Policy(id="policy-prowlarr-applications", action="prowlarr.application_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr app sync status is read-only."),
Policy(id="policy-prowlarr-indexer-health", action="prowlarr.indexer_health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer health is read-only."),
Policy(id="policy-prowlarr-sync-status", action="prowlarr.sync_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr sync status is read-only."),
Policy(id="policy-prowlarr-search-test", action="prowlarr.search_test", target="prowlarr", voice="approval_required", manual="allowed", risk="medium", reason="Search test consumes indexer API quota and is read-only controlled access."),
Policy(id="policy-prowlarr-delete-indexer", action="prowlarr.delete_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting indexers is destructive and not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-delete-application", action="prowlarr.delete_application", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting Prowlarr applications is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-update-indexer", action="prowlarr.update_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating indexers is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-update-application", action="prowlarr.update_application", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating Prowlarr applications is not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-grab-release", action="prowlarr.grab_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release grab/download is blocked in Prowlarr Connector V1."),
Policy(id="policy-prowlarr-push-release", action="prowlarr.push_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release push is blocked in Prowlarr Connector V1."),
Policy(id="policy-prowlarr-mass-edit", action="prowlarr.mass_edit", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."),
Policy(id="policy-prowlarr-test-all-indexers", action="prowlarr.test_all_indexers", target="prowlarr", voice="blocked", manual="blocked", risk="medium", reason="Testing all indexers may consume API quota and is disabled in V1."),
Policy(id="policy-deluge-health", action="deluge.health", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge health is read-only."),
Policy(id="policy-deluge-session", action="deluge.session_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge session status is read-only."),
Policy(id="policy-deluge-torrents", action="deluge.torrent_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge torrent status is read-only."),
Policy(id="policy-deluge-active", action="deluge.active_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading active downloads is read-only."),
Policy(id="policy-deluge-stalled", action="deluge.stalled_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading stalled downloads is read-only."),
Policy(id="policy-deluge-paused", action="deluge.paused_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading paused downloads is read-only."),
Policy(id="policy-deluge-speeds", action="deluge.speed_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge speed status is read-only."),
Policy(id="policy-deluge-pause-torrent", action="deluge.pause_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Pausing a torrent changes download state and should be operator-controlled."),
Policy(id="policy-deluge-resume-torrent", action="deluge.resume_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a torrent changes download state and should be operator-controlled."),
Policy(id="policy-deluge-pause-all", action="deluge.pause_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Pausing all downloads affects the media pipeline."),
Policy(id="policy-deluge-resume-all", action="deluge.resume_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Resuming all downloads affects the media pipeline."),
Policy(id="policy-deluge-remove-torrent", action="deluge.remove_torrent", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing torrents is destructive and blocked in V1."),
Policy(id="policy-deluge-remove-data", action="deluge.remove_torrent_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing downloaded data is blocked."),
Policy(id="policy-deluge-delete-data", action="deluge.delete_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloaded data is blocked."),
Policy(id="policy-deluge-force-recheck", action="deluge.force_recheck", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Force recheck is unsupported in V1."),
Policy(id="policy-deluge-move-storage", action="deluge.move_storage", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Moving torrent storage is unsupported in V1."),
Policy(id="policy-deluge-add-torrent", action="deluge.add_torrent", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding torrents is unsupported in V1."),
Policy(id="policy-deluge-add-magnet", action="deluge.add_magnet", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding magnets is unsupported in V1."),
Policy(id="policy-deluge-mass-edit", action="deluge.mass_edit", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are blocked in V1."),
Policy(id="policy-deluge-set-options", action="deluge.set_options", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Changing torrent options is blocked in V1."),
Policy(id="policy-plex-health", action="plex.health", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex health is read-only."),
Policy(id="policy-plex-server-status", action="plex.server_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server status is read-only."),
Policy(id="policy-plex-library-status", action="plex.library_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex library status is read-only."),
Policy(id="policy-plex-active-sessions", action="plex.active_sessions", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex active sessions is read-only."),
Policy(id="policy-plex-recent-media", action="plex.recent_media", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex recent media is read-only."),
Policy(id="policy-plex-stream-summary", action="plex.stream_summary", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex stream summary is read-only."),
Policy(id="policy-plex-scan-library", action="plex.scan_library", target="plex", voice="approval_required", manual="allowed", risk="medium", reason="Scanning a Plex library can create server load and should be operator-controlled."),
Policy(id="policy-plex-scan-all", action="plex.scan_all_libraries", target="plex", voice="blocked", manual="approval_required", risk="high", reason="Scanning all Plex libraries can create significant server load."),
Policy(id="policy-plex-delete-media", action="plex.delete_media", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex media is blocked."),
Policy(id="policy-plex-delete-library", action="plex.delete_library", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex libraries is blocked."),
Policy(id="policy-plex-empty-trash", action="plex.empty_trash", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Empty trash can remove media references and is blocked."),
Policy(id="policy-plex-clean-bundles", action="plex.clean_bundles", target="plex", voice="blocked", manual="blocked", risk="high", reason="Cleaning bundles is blocked in V1."),
Policy(id="policy-plex-optimize-db", action="plex.optimize_database", target="plex", voice="blocked", manual="blocked", risk="high", reason="Database optimization is blocked in V1."),
Policy(id="policy-plex-kill-session", action="plex.kill_session", target="plex", voice="blocked", manual="blocked", risk="high", reason="Killing user sessions is blocked in V1."),
Policy(id="policy-plex-user-management", action="plex.user_management", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex user management is blocked."),
Policy(id="policy-plex-force-refresh-all", action="plex.force_refresh_all_metadata", target="plex", voice="blocked", manual="blocked", risk="high", reason="Force refreshing all metadata is blocked in V1."),
Policy(id="policy-plex-mass-edit", action="plex.mass_edit", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex mass edits are blocked in V1."),
Policy(id="policy-tautulli-health", action="tautulli.health", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli health is read-only."),
Policy(id="policy-tautulli-status", action="tautulli.status", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli status is read-only."),
Policy(id="policy-tautulli-activity", action="tautulli.activity", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading current Tautulli activity is read-only."),
Policy(id="policy-tautulli-history", action="tautulli.history", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Viewing watch history may expose user activity."),
Policy(id="policy-tautulli-users", action="tautulli.users", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="User statistics may expose personal viewing behavior."),
Policy(id="policy-tautulli-libraries", action="tautulli.libraries", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli libraries is read-only."),
Policy(id="policy-tautulli-recently-added", action="tautulli.recently_added", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading recently added media is read-only."),
Policy(id="policy-tautulli-watch-stats", action="tautulli.watch_stats", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Watch statistics may expose personal viewing behavior."),
Policy(id="policy-tautulli-stream-summary", action="tautulli.stream_summary", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading stream summary is read-only."),
Policy(id="policy-tautulli-plex-info", action="tautulli.plex_info", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server info through Tautulli is read-only."),
Policy(id="policy-tautulli-terminate-session", action="tautulli.terminate_session", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Terminating sessions is blocked in V1."),
Policy(id="policy-tautulli-delete-newsletter", action="tautulli.delete_newsletter", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting newsletters is blocked in V1."),
Policy(id="policy-tautulli-delete-notification-log", action="tautulli.delete_notification_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notification logs is blocked in V1."),
Policy(id="policy-tautulli-delete-notifier", action="tautulli.delete_notifier", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notifiers is blocked in V1."),
Policy(id="policy-tautulli-delete-recently-added", action="tautulli.delete_recently_added", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting recently-added records is blocked in V1."),
Policy(id="policy-tautulli-download-config", action="tautulli.download_config", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli config is blocked in V1."),
Policy(id="policy-tautulli-download-database", action="tautulli.download_database", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli database is blocked in V1."),
Policy(id="policy-tautulli-download-export", action="tautulli.download_export", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli exports is blocked in V1."),
Policy(id="policy-tautulli-download-log", action="tautulli.download_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli logs is blocked in V1."),
Policy(id="policy-tautulli-download-plex-log", action="tautulli.download_plex_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Plex logs through Tautulli is blocked in V1."),
Policy(id="policy-tautulli-notify", action="tautulli.notify", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Sending notifications through Tautulli is blocked in V1."),
Policy(id="policy-tautulli-user-management", action="tautulli.user_management", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli user management is blocked in V1."),
Policy(id="policy-tautulli-mass-edit", action="tautulli.mass_edit", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli mass edits are blocked in V1."),
Policy(id="policy-npm-health", action="npm.health", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM health is read-only."),
Policy(id="policy-npm-proxy-hosts", action="npm.proxy_hosts", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Proxy hosts may reveal external exposure."),
Policy(id="policy-npm-certificates", action="npm.certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading certificate metadata is read-only."),
Policy(id="policy-npm-expiring-certificates", action="npm.expiring_certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading expiring certificate metadata is read-only."),
Policy(id="policy-npm-access-lists", action="npm.access_lists", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Access list information may reveal security structure."),
Policy(id="policy-npm-exposure-summary", action="npm.exposure_summary", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="medium", reason="Exposure summary is a read-only security overview."),
Policy(id="policy-npm-host-reachability", action="npm.host_reachability", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="V1 host reachability is metadata-only."),
Policy(id="policy-npm-security-warnings", action="npm.security_warnings", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM security warnings is read-only."),
Policy(id="policy-npm-create-proxy-host", action="npm.create_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Creating proxy hosts is not supported by AppOps Gateway V1."),
Policy(id="policy-npm-update-proxy-host", action="npm.update_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Updating proxy hosts is not supported by AppOps Gateway V1."),
Policy(id="policy-npm-delete-proxy-host", action="npm.delete_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting proxy hosts is destructive and blocked."),
Policy(id="policy-npm-enable-proxy-host", action="npm.enable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Enabling proxy hosts changes external exposure and is blocked in V1."),
Policy(id="policy-npm-disable-proxy-host", action="npm.disable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Disabling proxy hosts changes external exposure and is blocked in V1."),
Policy(id="policy-npm-renew-certificate", action="npm.renew_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Certificate renewal is not executed by AppOps Gateway V1."),
Policy(id="policy-npm-delete-certificate", action="npm.delete_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting certificates is blocked."),
Policy(id="policy-npm-modify-access-list", action="npm.modify_access_list", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Access list mutations are blocked in V1."),
Policy(id="policy-npm-create-user", action="npm.create_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-update-user", action="npm.update_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-delete-user", action="npm.delete_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."),
Policy(id="policy-npm-mass-edit", action="npm.mass_edit", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM bulk mutations are blocked."),
Policy(id="policy-npm-unknown-mutation", action="npm.unknown_mutation", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Unknown NPM mutations fail closed."),
Policy(id="policy-authentik-health", action="authentik.health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik health is read-only."),
Policy(id="policy-authentik-system", action="authentik.system_status", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik system status is read-only."),
Policy(id="policy-authentik-applications", action="authentik.applications", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Applications reveal SSO coverage and are operator-visible."),
Policy(id="policy-authentik-providers", action="authentik.providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Provider metadata can reveal security topology."),
Policy(id="policy-authentik-proxy-providers", action="authentik.proxy_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Proxy provider metadata can reveal exposure topology."),
Policy(id="policy-authentik-oauth2-providers", action="authentik.oauth2_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="OAuth provider metadata can reveal application integrations."),
Policy(id="policy-authentik-outposts", action="authentik.outposts", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Outpost metadata is security-topology information."),
Policy(id="policy-authentik-outpost-health", action="authentik.outpost_health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Outpost health summary is read-only."),
Policy(id="policy-authentik-flows", action="authentik.flows_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Flow summaries reveal authentication design."),
Policy(id="policy-authentik-policies", action="authentik.policies_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Policy summaries reveal security controls."),
Policy(id="policy-authentik-events", action="authentik.events", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Events may expose user activity and require operator context."),
Policy(id="policy-authentik-auth-coverage", action="authentik.auth_coverage", target="authentik", voice="allowed", manual="allowed", risk="medium", reason="Auth coverage is a read-only security overview."),
Policy(id="policy-authentik-security-warnings", action="authentik.security_warnings", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik security warnings is read-only."),
Policy(id="policy-authentik-create-application", action="authentik.create_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-application", action="authentik.update_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-application", action="authentik.delete_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-provider", action="authentik.create_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-provider", action="authentik.update_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-provider", action="authentik.delete_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-outpost", action="authentik.create_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-outpost", action="authentik.update_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-outpost", action="authentik.delete_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-policy", action="authentik.create_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-update-policy", action="authentik.update_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-delete-policy", action="authentik.delete_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."),
Policy(id="policy-authentik-create-user", action="authentik.create_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-update-user", action="authentik.update_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-delete-user", action="authentik.delete_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."),
Policy(id="policy-authentik-create-group", action="authentik.create_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-update-group", action="authentik.update_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-delete-group", action="authentik.delete_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."),
Policy(id="policy-authentik-rotate-token", action="authentik.rotate_token", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Token rotation is never automated by Connector V1."),
Policy(id="policy-authentik-force-sync", action="authentik.force_sync", target="authentik", voice="blocked", manual="blocked", risk="high", reason="Force sync is a mutating operation and blocked in Connector V1."),
Policy(id="policy-authentik-mass-edit", action="authentik.mass_edit", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Bulk Authentik mutations are blocked."),
Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."),
]
+398
View File
@@ -0,0 +1,398 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, CheckConstraint
from sqlmodel import Column, Field, SQLModel
from ..core.time import utc_now
class App(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
category: str
status: str = Field(default="healthy")
criticality: str = Field(default="low")
connector_status: str = Field(default="disconnected")
container_status: str = Field(default="stopped")
voice_enabled: bool = Field(default=False)
protected: bool = Field(default=False)
last_action: str | None = None
discovery_status: str | None = None
discovery_source: str | None = None
needs_review: bool = Field(default=False)
created_at: datetime = Field(default_factory=utc_now)
class Connector(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
status: str = Field(default="disconnected")
last_test: str = Field(default="never")
secret_ref: str | None = None
capabilities: list[str] = Field(default_factory=list, sa_column=Column(JSON))
type: str = Field(default="Infrastructure")
class NotificationRule(SQLModel, table=True):
__tablename__ = "notification_rules"
id: str = Field(primary_key=True)
name: str
enabled: bool = Field(default=True)
source: str
event_type: str
severity_min: str = Field(default="medium")
categories_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
targets_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
channels_json: list[str] = Field(default_factory=lambda: ["dashboard"], sa_column=Column(JSON))
cooldown_seconds: int = Field(default=3600)
quiet_hours_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
conditions_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
dedupe_key_template: str = Field(default="{source}:{event_type}:{target}:{category}")
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class NotificationEvent(SQLModel, table=True):
__tablename__ = "notification_events"
id: str = Field(primary_key=True)
rule_id: str
source: str
event_type: str
severity: str
title: str
message: str
category: str | None = None
target: str | None = None
dedupe_key: str
channels_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
status: str = Field(default="queued")
reason: str | None = None
payload_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
read: bool = Field(default=False)
created_at: datetime = Field(default_factory=utc_now)
sent_at: datetime | None = None
suppressed_until: datetime | None = None
class NotificationChannel(SQLModel, table=True):
__tablename__ = "notification_channels"
id: str = Field(primary_key=True)
type: str = Field(default="dashboard")
name: str
enabled: bool = Field(default=True)
config_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
secret_refs_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
last_test_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class AppSetting(SQLModel, table=True):
__tablename__ = "app_settings"
key: str = Field(primary_key=True)
value_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
is_secret: bool = Field(default=False)
updated_at: datetime = Field(default_factory=utc_now)
class Secret(SQLModel, table=True):
__tablename__ = "secrets"
id: str = Field(primary_key=True)
name: str = Field(index=True)
encrypted_value: str
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
last_used_at: datetime | None = None
used_by_json: list[str] = Field(default_factory=list, sa_column=Column(JSON))
metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class Policy(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
target: str
voice: str
manual: str
risk: str
reason: str | None = None
provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class AuditEvent(SQLModel, table=True):
id: str = Field(primary_key=True)
timestamp: datetime = Field(default_factory=utc_now)
source: str
identity: str = Field(default="manual")
action_id: str
target: str
target_type: str = Field(default="app")
risk: str
policy_decision: str
reason: str | None = None
execution_plan: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
result: str = Field(default="pending")
before_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
after_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
post_checks: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
redacted_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
class Approval(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
target: str
source: str = Field(default="manual")
risk: str
status: str = Field(default="pending")
requester: str = Field(default="system")
approved_by: str | None = None
executed_by: str | None = None
action_fingerprint: str | None = None
request_context_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
reason: str | None = None
expires_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now)
decided_at: datetime | None = None
executed_at: datetime | None = None
dismissed_at: datetime | None = None
job_id: str | None = None
audit_event_id: str | None = None
class JobRun(SQLModel, table=True):
id: str = Field(primary_key=True)
type: str
status: str = Field(default="queued")
source: str = Field(default="manual")
action_id: str
target: str
risk: str
policy_decision: str
steps_json: str = Field(default="[]")
progress: int = Field(default=0)
started_at: datetime | None = Field(default=None)
finished_at: datetime | None = Field(default=None)
duration_ms: int | None = None
audit_event_id: str | None = None
result_summary: str | None = None
class DeploymentJob(SQLModel, table=True):
"""Durable, redacted state for one Deployment Control Plane request.
The plan is immutable and excludes secret values. Secrets remain in the
encrypted Secret store and are resolved only at the bounded controller
request boundary.
"""
__tablename__ = "deployment_jobs"
id: str = Field(primary_key=True)
application_id: str = Field(index=True)
repository: str = Field(index=True)
revision: str = Field(index=True)
plan_sha256: str = Field(index=True)
idempotency_sha256: str = Field(index=True)
status: str = Field(default="queued", index=True)
stage: str = Field(default="accepted")
plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
broker_request_id: str | None = Field(default=None, index=True)
result_summary: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class WorkMeshPromotion(SQLModel, table=True):
"""Durable approval and reconciliation state for fixed WorkMesh promotion."""
__tablename__ = "workmesh_promotions"
id: str = Field(primary_key=True)
revision: str = Field(index=True)
preflight_sha256: str = Field(index=True)
idempotency_sha256: str = Field(index=True, unique=True)
status: str = Field(default="approval_required", index=True)
stage: str = Field(default="preflight_accepted")
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
approval_id: str | None = Field(default=None, index=True)
broker_request_id: str | None = Field(default=None, index=True)
result_summary: str | None = None
audit_event_id: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class Enrollment(SQLModel, table=True):
"""One immutable application-management boundary established by AppOps."""
__tablename__ = "enrollments"
id: str = Field(primary_key=True)
application_id: str = Field(index=True)
repository: str = Field(index=True)
revision: str = Field(index=True)
profile: str = Field(index=True)
controller_identity: str = Field(index=True)
plan_sha256: str = Field(index=True)
status: str = Field(default="planned", index=True)
target_secret_ref: str = Field(index=True)
oidc_secret_ref: str | None = Field(default=None, index=True)
plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
resources_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
last_verified_at: datetime | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class EnrollmentJob(SQLModel, table=True):
"""Restart-safe, redacted evidence for enrollment orchestration."""
__tablename__ = "enrollment_jobs"
id: str = Field(primary_key=True)
enrollment_id: str = Field(index=True)
application_id: str = Field(index=True)
operation: str = Field(default="enroll", index=True)
idempotency_sha256: str = Field(index=True)
status: str = Field(default="queued", index=True)
stage: str = Field(default="planned")
preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
result_summary: str | None = None
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
completed_at: datetime | None = None
class RepoPulseAdoptionJournal(SQLModel, table=True):
"""One durable protocol intent for the fixed RepoPulse legacy adoption."""
__tablename__ = "repopulse_adoption_journal"
__table_args__ = (
CheckConstraint(
"slot = 'repopulse' AND repository = 'jens/repopulse' "
"AND application_id = 'repopulse'",
name="ck_repopulse_adoption_fixed_identity",
),
)
slot: str = Field(default="repopulse", primary_key=True)
repository: str
application_id: str
intent_id: str = Field(index=True)
idempotency_sha256: str
preflight_sha256: str
phase: str
status: str
revision: int = Field(default=1, ge=1)
intent_json: dict[str, Any] = Field(sa_column=Column(JSON, nullable=False))
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class HealthCheck(SQLModel, table=True):
id: str = Field(primary_key=True)
service: str
status: str
checked_at: datetime = Field(default_factory=utc_now)
details: str | None = None
class AiMemoryItem(SQLModel, table=True):
id: str = Field(primary_key=True)
key: str
value: str
category: str = Field(default="app_note")
pinned: bool = Field(default=False)
updated_at: datetime = Field(default_factory=utc_now)
class AiRecommendation(SQLModel, table=True):
id: str = Field(primary_key=True)
action: str
title: str
reasoning: str
target: str
severity: str
suggested_actions: list[str] = Field(default_factory=list, sa_column=Column(JSON))
class KnowledgeChunk(SQLModel, table=True):
__tablename__ = "knowledge_chunks"
id: str = Field(primary_key=True)
source_uri: str = Field(index=True)
source_type: str = Field(default="documentation", index=True)
title: str
section: str | None = None
content: str
content_hash: str = Field(index=True)
updated_at: datetime = Field(default_factory=utc_now)
class DiscoveryRun(SQLModel, table=True):
__tablename__ = "discovery_runs"
id: str = Field(primary_key=True)
source: str = Field(default="portainer")
started_at: datetime = Field(default_factory=utc_now, index=True)
finished_at: datetime | None = None
status: str = Field(default="running")
containers_found: int = Field(default=0)
apps_matched: int = Field(default=0)
new_items: int = Field(default=0)
changed_items: int = Field(default=0)
errors_json: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
class DiscoveredContainer(SQLModel, table=True):
__tablename__ = "discovered_containers"
id: str = Field(primary_key=True)
discovery_run_id: str = Field(index=True)
container_id: str = Field(index=True)
name: str
image: str
state: str = Field(default="unknown")
status: str = Field(default="unknown")
ports_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
mounts_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
networks_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
labels_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
suggested_app_type: str = Field(default="unknown")
suggested_category: str = Field(default="unknown")
suggested_criticality: str = Field(default="low")
suggested_connector: str = Field(default="none")
suggested_policy_profile: str = Field(default="default")
import_status: str = Field(default="discovered")
matched_app_id: str | None = None
raw_metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class AppContainerMapping(SQLModel, table=True):
__tablename__ = "app_container_mappings"
id: str = Field(primary_key=True)
app_id: str = Field(index=True)
container_id: str = Field(index=True)
container_name: str
role: str = Field(default="primary")
source: str = Field(default="discovery")
is_primary: bool = Field(default=True)
last_seen_run_id: str | None = None
last_seen_at: datetime = Field(default_factory=utc_now)
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
from sqlalchemy import event
from sqlalchemy.engine import Engine
from sqlmodel import create_engine, Session
from ..core.config import settings
def _configure_sqlite_connection(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
finally:
cursor.close()
def create_app_engine(db_path: Path | str) -> Engine:
resolved_path = Path(db_path)
if resolved_path.parent != Path('.'):
resolved_path.parent.mkdir(parents=True, exist_ok=True)
app_engine = create_engine(
f"sqlite:///{resolved_path}",
echo=False,
connect_args={"check_same_thread": False, "timeout": 30},
pool_size=20,
max_overflow=20,
pool_timeout=10,
)
event.listen(app_engine, "connect", _configure_sqlite_connection)
return app_engine
_db_path = Path(settings.db_path)
DATABASE_URL = f"sqlite:///{_db_path}"
engine = create_app_engine(_db_path)
def get_session():
with Session(engine) as session:
yield session