keywright-server 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
keywright_server/db.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import event, inspect, text
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
8
|
+
|
|
9
|
+
from .config import settings
|
|
10
|
+
from .models import Base
|
|
11
|
+
|
|
12
|
+
_engine: AsyncEngine | None = None
|
|
13
|
+
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def engine() -> AsyncEngine:
|
|
17
|
+
global _engine
|
|
18
|
+
if _engine is None:
|
|
19
|
+
_engine = create_async_engine(settings.db_url, future=True)
|
|
20
|
+
if settings.db_url.startswith("sqlite"):
|
|
21
|
+
|
|
22
|
+
@event.listens_for(_engine.sync_engine, "connect")
|
|
23
|
+
def _pragmas(dbapi_conn, _record): # pragma: no cover - trivial
|
|
24
|
+
cur = dbapi_conn.cursor()
|
|
25
|
+
cur.execute("PRAGMA journal_mode=WAL")
|
|
26
|
+
cur.execute("PRAGMA foreign_keys=ON")
|
|
27
|
+
cur.execute("PRAGMA busy_timeout=5000")
|
|
28
|
+
cur.close()
|
|
29
|
+
|
|
30
|
+
return _engine
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sessionmaker() -> async_sessionmaker[AsyncSession]:
|
|
34
|
+
global _sessionmaker
|
|
35
|
+
if _sessionmaker is None:
|
|
36
|
+
_sessionmaker = async_sessionmaker(engine(), expire_on_commit=False)
|
|
37
|
+
return _sessionmaker
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def session() -> AsyncIterator[AsyncSession]:
|
|
42
|
+
async with sessionmaker()() as s:
|
|
43
|
+
yield s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def init_db() -> None:
|
|
47
|
+
settings.ensure_dirs()
|
|
48
|
+
async with engine().begin() as conn:
|
|
49
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
50
|
+
await conn.run_sync(_upgrade_knowledge_columns)
|
|
51
|
+
from .knowledge_index import init_local
|
|
52
|
+
await conn.run_sync(init_local)
|
|
53
|
+
await conn.run_sync(_upgrade_access_columns)
|
|
54
|
+
await conn.run_sync(_upgrade_discovery_columns)
|
|
55
|
+
await conn.run_sync(_relocate_key_paths)
|
|
56
|
+
await conn.execute(text("SELECT 1"))
|
|
57
|
+
if settings.db_url.startswith("sqlite") and settings.db_path.exists():
|
|
58
|
+
settings.db_path.chmod(0o600)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _upgrade_knowledge_columns(conn) -> None:
|
|
62
|
+
# Add metadata capacity only. Existing published chunks require explicit admin migration.
|
|
63
|
+
columns = {c["name"] for c in inspect(conn).get_columns("knowledge_chunks")}
|
|
64
|
+
if "section_path" not in columns:
|
|
65
|
+
conn.execute(text("ALTER TABLE knowledge_chunks ADD COLUMN section_path JSON"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _upgrade_access_columns(conn) -> None:
|
|
69
|
+
"""Add nullable access constraints without changing existing authorization semantics.
|
|
70
|
+
|
|
71
|
+
Run startup migrations before starting additional workers against the same database.
|
|
72
|
+
"""
|
|
73
|
+
for table, column in (("grants", "constraints"), ("policies", "constraints"),
|
|
74
|
+
("requests", "decision_options"), ("policies", "request_id"),
|
|
75
|
+
("requests", "semantic")):
|
|
76
|
+
columns = {c["name"] for c in inspect(conn).get_columns(table)}
|
|
77
|
+
if column not in columns:
|
|
78
|
+
column_type = "VARCHAR" if column == "request_id" else "JSON"
|
|
79
|
+
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN "{column}" {column_type}'))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _upgrade_discovery_columns(conn) -> None:
|
|
83
|
+
columns = {c['name'] for c in inspect(conn).get_columns('inventory_discovery_runs')}
|
|
84
|
+
if 'source_fingerprint' not in columns:
|
|
85
|
+
conn.execute(text('ALTER TABLE inventory_discovery_runs ADD COLUMN source_fingerprint VARCHAR'))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _relocate_key_paths(conn) -> None:
|
|
89
|
+
"""A resource stores its SSH key as an absolute path. When the data dir moves (the Gatekeeper
|
|
90
|
+
rename left /var/lib/gatekeeper/keys/<name> behind, a container mounts the dir somewhere
|
|
91
|
+
else), a stored path that no longer exists is pointed at the same file name in this
|
|
92
|
+
instance's keys dir, if that exists. Never invents a key: paths outside keys_dir that still
|
|
93
|
+
exist are the admin's and stay as they are."""
|
|
94
|
+
import json
|
|
95
|
+
import os
|
|
96
|
+
|
|
97
|
+
rows = conn.execute(text("SELECT id, config FROM resources WHERE kind = 'ssh'")).fetchall()
|
|
98
|
+
for rid, raw in rows:
|
|
99
|
+
cfg = json.loads(raw) if isinstance(raw, str) else dict(raw or {})
|
|
100
|
+
kp = cfg.get("key_path") or ""
|
|
101
|
+
if not kp or os.path.exists(kp):
|
|
102
|
+
continue
|
|
103
|
+
candidate = settings.keys_dir / os.path.basename(kp)
|
|
104
|
+
if candidate.exists():
|
|
105
|
+
cfg["key_path"] = str(candidate)
|
|
106
|
+
conn.execute(text("UPDATE resources SET config = :c WHERE id = :i"), {"c": json.dumps(cfg), "i": rid})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def reset_for_tests(url: str) -> None:
|
|
110
|
+
"""Point the module at a fresh engine (tests only)."""
|
|
111
|
+
global _engine, _sessionmaker
|
|
112
|
+
if _engine is not None:
|
|
113
|
+
await _engine.dispose()
|
|
114
|
+
_engine = create_async_engine(url, future=True)
|
|
115
|
+
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
|
116
|
+
async with _engine.begin() as conn:
|
|
117
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
118
|
+
from .knowledge_index import init_local
|
|
119
|
+
await conn.run_sync(init_local)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Read-only source observations; explicit inventory imports live in reconciliation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
from . import audit, omada, ssh
|
|
14
|
+
from . import inventory as inv
|
|
15
|
+
from .db import session
|
|
16
|
+
from .models import DiscoveryObservation as Observation
|
|
17
|
+
from .models import DiscoveryRun as Run
|
|
18
|
+
from .models import Resource, now
|
|
19
|
+
|
|
20
|
+
PROVIDERS = {'omada': 'Omada', 'proxmox': 'Proxmox'}
|
|
21
|
+
PVE_COMMAND = 'pvesh get /cluster/resources --output-format json'
|
|
22
|
+
MAX_ROWS = 10000
|
|
23
|
+
MAX_SECONDS = 180
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DiscoveryError(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def source_fingerprint(config):
|
|
31
|
+
"""Detect source edits without retaining a second copy of its credentials."""
|
|
32
|
+
keys = ('host', 'port', 'user', 'key_path', 'host_key', 'adapter', 'url', 'omada_id',
|
|
33
|
+
'client_id', 'client_secret', 'verify_tls', 'ca_bundle')
|
|
34
|
+
return hashlib.sha256(json.dumps({key: config[key] for key in keys if key in config},
|
|
35
|
+
sort_keys=True).encode()).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def label(value, maximum=160):
|
|
39
|
+
return ''.join(c for c in str(value or '') if ord(c) >= 32)[:maximum].strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mac(value):
|
|
43
|
+
value = label(value).lower().replace('-', ':')
|
|
44
|
+
return value if re.fullmatch(r'(?:[0-9a-f]{2}:){5}[0-9a-f]{2}', value) else ''
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def addresses(row):
|
|
48
|
+
values = [row.get('ip'), *(row.get('ipv6List') or [])]
|
|
49
|
+
result = set()
|
|
50
|
+
for raw in values:
|
|
51
|
+
try:
|
|
52
|
+
ip = ipaddress.ip_address(raw)
|
|
53
|
+
if ip.is_unspecified or ip.is_multicast or ip.is_loopback or ip.is_link_local:
|
|
54
|
+
continue
|
|
55
|
+
result.add(str(ip))
|
|
56
|
+
except (ValueError, TypeError):
|
|
57
|
+
continue
|
|
58
|
+
return sorted(result, key=inv.address_key)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def pages(config, tool, **args):
|
|
62
|
+
rows, total = [], None
|
|
63
|
+
for page in range(1, 102):
|
|
64
|
+
result = await omada.request(config, tool, {**args, 'page': page, 'page_size': 100})
|
|
65
|
+
if (not isinstance(result, dict) or not isinstance(result.get('data'), list)
|
|
66
|
+
or type(result.get('totalRows')) is not int or not 0 <= result['totalRows'] <= MAX_ROWS
|
|
67
|
+
or type(result.get('currentPage')) is not int or result['currentPage'] != page
|
|
68
|
+
or any(not isinstance(r, dict) for r in result['data'])):
|
|
69
|
+
raise DiscoveryError('The controller returned an invalid inventory page. No observations were replaced.')
|
|
70
|
+
if total is not None and result['totalRows'] != total:
|
|
71
|
+
raise DiscoveryError('The controller inventory changed during pagination. Refresh again.')
|
|
72
|
+
total = result['totalRows']
|
|
73
|
+
batch = result['data']
|
|
74
|
+
rows.extend(batch)
|
|
75
|
+
if len(rows) > total or len(rows) > MAX_ROWS:
|
|
76
|
+
raise DiscoveryError('The controller returned inconsistent inventory counts.')
|
|
77
|
+
if len(rows) == total:
|
|
78
|
+
return rows
|
|
79
|
+
if not batch:
|
|
80
|
+
break
|
|
81
|
+
raise DiscoveryError('The controller returned an incomplete inventory. Previous observations were retained.')
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def omada_row(row, site, category):
|
|
85
|
+
identity = mac(row.get('mac'))
|
|
86
|
+
if not identity:
|
|
87
|
+
raise DiscoveryError('An Omada record has no valid MAC identity. Previous observations were retained.')
|
|
88
|
+
wireless = row.get('wireless')
|
|
89
|
+
medium = ('wifi' if wireless else 'wired') if type(wireless) is bool else 'unknown'
|
|
90
|
+
attachment = mac(row.get('apMac') if wireless else row.get('switchMac'))
|
|
91
|
+
# Preserve explicit source state; numeric device status is deliberately not interpreted as a ping.
|
|
92
|
+
status = ('Active' if row['active'] else 'Inactive') if type(row.get('active')) is bool else 'Unknown'
|
|
93
|
+
return {'external_id': f"{site['siteId']}/{category}/{identity}", 'data': {
|
|
94
|
+
'name': label(row.get('name') or row.get('hostName') or identity),
|
|
95
|
+
'kind': 'network' if category == 'device' else 'device', 'site': label(site.get('name')),
|
|
96
|
+
'mac': identity, 'addresses': addresses(row), 'medium': medium,
|
|
97
|
+
'attachment': f"{site['siteId']}/device/{attachment}" if attachment else '',
|
|
98
|
+
'port': label(row.get('ssid') if wireless else row.get('standardPort') or row.get('port')),
|
|
99
|
+
'parent': '', 'status': status,
|
|
100
|
+
'device_type': label(row.get('type')) if category == 'device' else 'client',
|
|
101
|
+
'model': label(row.get('model')) if category == 'device' else '',
|
|
102
|
+
}}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ap_uplink(data, detail, site_id):
|
|
106
|
+
"""The AP detail endpoint distinguishes a wired AP from a wireless mesh uplink."""
|
|
107
|
+
if not isinstance(detail, dict) or type(detail.get('wirelessLinked')) is not bool:
|
|
108
|
+
data['uplink_note'] = 'The controller did not report this AP’s uplink type.'
|
|
109
|
+
return
|
|
110
|
+
wireless = detail['wirelessLinked']
|
|
111
|
+
data['medium'] = 'wifi' if wireless else 'wired'
|
|
112
|
+
data['uplink_note'] = ''
|
|
113
|
+
if wireless:
|
|
114
|
+
data['attachment'], data['port'] = '', ''
|
|
115
|
+
uplink = detail.get('wirelessUplink')
|
|
116
|
+
parent = mac(uplink.get('uplinkMac')) if isinstance(uplink, dict) else ''
|
|
117
|
+
if parent and parent != data['mac']:
|
|
118
|
+
data['attachment'] = f'{site_id}/device/{parent}'
|
|
119
|
+
data['port'] = 'Mesh'
|
|
120
|
+
else:
|
|
121
|
+
data['uplink_note'] = 'Mesh uplink reported, but its parent AP was not identified.'
|
|
122
|
+
else:
|
|
123
|
+
data['uplink_note'] = 'Wired uplink; its upstream device is not reported by this endpoint.'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def collect_omada(config):
|
|
127
|
+
records = []
|
|
128
|
+
for site in await pages(config, 'omada_list_sites'):
|
|
129
|
+
if not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', str(site.get('siteId', ''))):
|
|
130
|
+
raise DiscoveryError('The controller returned an invalid site identity.')
|
|
131
|
+
for category, tool in [('device', 'omada_list_devices'), ('client', 'omada_list_clients')]:
|
|
132
|
+
for row in await pages(config, tool, site_id=site['siteId']):
|
|
133
|
+
record = omada_row(row, site, category)
|
|
134
|
+
if category == 'device' and row.get('type') == 'ap':
|
|
135
|
+
try:
|
|
136
|
+
detail = await omada.request(config, 'omada_get_ap',
|
|
137
|
+
{'site_id': site['siteId'], 'ap_mac': row['mac']})
|
|
138
|
+
except omada.OmadaError:
|
|
139
|
+
# Older controllers or app permissions may omit details. Keep client discovery usable,
|
|
140
|
+
# but do not carry forward an old mesh parent as if it were freshly observed.
|
|
141
|
+
record['data']['uplink_note'] = 'AP uplink details are unavailable from the controller.'
|
|
142
|
+
else:
|
|
143
|
+
ap_uplink(record['data'], detail, site['siteId'])
|
|
144
|
+
records.append(record)
|
|
145
|
+
if len(records) > MAX_ROWS:
|
|
146
|
+
raise DiscoveryError('Inventory exceeds the discovery limit; previous observations were retained.')
|
|
147
|
+
return records
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def proxmox_rows(rows):
|
|
151
|
+
if not isinstance(rows, list) or len(rows) > MAX_ROWS or any(not isinstance(r, dict) for r in rows):
|
|
152
|
+
raise DiscoveryError('Proxmox returned an invalid cluster inventory.')
|
|
153
|
+
result = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
kind = {'node': 'physical', 'qemu': 'vm', 'lxc': 'container'}.get(row.get('type'))
|
|
156
|
+
if not kind:
|
|
157
|
+
continue
|
|
158
|
+
node = label(row.get('node'))
|
|
159
|
+
if not re.fullmatch(r'[A-Za-z0-9_.-]+', node):
|
|
160
|
+
raise DiscoveryError('Proxmox returned an invalid node identity.')
|
|
161
|
+
if kind != 'physical' and (type(row.get('vmid')) is not int or row['vmid'] < 1):
|
|
162
|
+
raise DiscoveryError('Proxmox returned an invalid guest identity.')
|
|
163
|
+
external_id = f"node/{node}" if kind == 'physical' else f"{row['type']}/{row['vmid']}"
|
|
164
|
+
result.append({'external_id': external_id, 'data': {
|
|
165
|
+
'name': label(row.get('name') or (node if kind == 'physical' else external_id)),
|
|
166
|
+
'kind': kind, 'site': '', 'mac': '', 'addresses': [], 'medium': 'unknown',
|
|
167
|
+
'attachment': '', 'port': '', 'parent': f'node/{node}' if kind != 'physical' else '',
|
|
168
|
+
'status': label(row.get('status') or 'Unknown'),
|
|
169
|
+
}})
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def collect(provider, config):
|
|
174
|
+
if provider == 'omada':
|
|
175
|
+
return await collect_omada(config)
|
|
176
|
+
result = await ssh.run(config, PVE_COMMAND, timeout=60)
|
|
177
|
+
if result.exit_code != 0 or result.truncated:
|
|
178
|
+
raise DiscoveryError('Proxmox inventory could not be read completely. Check the SSH host and pvesh access.')
|
|
179
|
+
try:
|
|
180
|
+
return proxmox_rows(json.loads(result.stdout))
|
|
181
|
+
except (ValueError, TypeError) as exc:
|
|
182
|
+
if isinstance(exc, DiscoveryError):
|
|
183
|
+
raise
|
|
184
|
+
raise DiscoveryError('Proxmox returned invalid inventory JSON.') from None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def start_run(resource_id, provider, *, min_interval=0):
|
|
188
|
+
async with session() as db:
|
|
189
|
+
await inv.lock_writes(db)
|
|
190
|
+
res = await inv.require(db, Resource, resource_id)
|
|
191
|
+
if (provider not in PROVIDERS or provider == 'omada' and
|
|
192
|
+
(res.kind != 'mcp' or res.config.get('adapter') != 'omada') or
|
|
193
|
+
provider == 'proxmox' and (res.kind != 'ssh' or not res.config.get('host_key'))):
|
|
194
|
+
raise DiscoveryError('Choose an Omada controller or a Proxmox SSH resource with a pinned host key.')
|
|
195
|
+
running = await db.scalar(select(Run).where(Run.resource_id == resource_id, Run.provider == provider,
|
|
196
|
+
Run.status == 'running', Run.started_at > now() - timedelta(minutes=5)))
|
|
197
|
+
if running:
|
|
198
|
+
raise DiscoveryError('A refresh for this source is already running.')
|
|
199
|
+
if min_interval:
|
|
200
|
+
recent = await db.scalar(select(Run.id).where(
|
|
201
|
+
Run.resource_id == resource_id, Run.provider == provider,
|
|
202
|
+
Run.started_at > now() - timedelta(seconds=min_interval)))
|
|
203
|
+
if recent:
|
|
204
|
+
raise DiscoveryError(f'This source was refreshed recently. Wait {min_interval} seconds between refreshes.')
|
|
205
|
+
run = Run(resource_id=resource_id, provider=provider, source_fingerprint=source_fingerprint(res.config))
|
|
206
|
+
db.add(run)
|
|
207
|
+
await db.flush()
|
|
208
|
+
await audit.record(db, tool='inventory.discovery.start', decision='started',
|
|
209
|
+
detail={'run_id': run.id, 'resource_id': resource_id, 'provider': provider})
|
|
210
|
+
return run.id, dict(res.config)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def finish_run(run_id, records=None, error=''):
|
|
214
|
+
async with session() as db:
|
|
215
|
+
await inv.lock_writes(db)
|
|
216
|
+
run = await inv.require(db, Run, run_id)
|
|
217
|
+
resource = await inv.require(db, Resource, run.resource_id)
|
|
218
|
+
if run.source_fingerprint != source_fingerprint(resource.config):
|
|
219
|
+
error = 'The source configuration changed during refresh. Refresh again before reviewing its records.'
|
|
220
|
+
latest = await db.scalar(select(Run.id).where(Run.resource_id == run.resource_id,
|
|
221
|
+
Run.provider == run.provider).order_by(Run.started_at.desc(), Run.id.desc()).limit(1))
|
|
222
|
+
if latest != run.id:
|
|
223
|
+
error = 'A newer refresh superseded this one; its results were not applied.'
|
|
224
|
+
if not error:
|
|
225
|
+
identities = [r['external_id'] for r in records]
|
|
226
|
+
if len(identities) != len(set(identities)) or len(records) > MAX_ROWS:
|
|
227
|
+
error = 'The source returned duplicate identities or too many records. Previous observations were retained.'
|
|
228
|
+
if not error:
|
|
229
|
+
existing = {r.external_id: r for r in (await db.scalars(select(Observation).where(
|
|
230
|
+
Observation.resource_id == run.resource_id, Observation.provider == run.provider))).all()}
|
|
231
|
+
at = now()
|
|
232
|
+
for record in records:
|
|
233
|
+
row = existing.get(record['external_id'])
|
|
234
|
+
if row is None:
|
|
235
|
+
row = Observation(resource_id=run.resource_id, provider=run.provider,
|
|
236
|
+
external_id=record['external_id'], first_seen=at)
|
|
237
|
+
db.add(row)
|
|
238
|
+
elif row.data != record['data']:
|
|
239
|
+
row.previous, row.changed_at = row.data, at
|
|
240
|
+
row.data, row.last_seen, row.run_id = record['data'], at, run.id
|
|
241
|
+
run.count = len(records)
|
|
242
|
+
run.status, run.error, run.finished_at = ('failed' if error else 'succeeded'), error, now()
|
|
243
|
+
await audit.record(db, tool='inventory.discovery.finish', decision=run.status,
|
|
244
|
+
detail={'run_id': run.id, 'count': run.count, 'error': error})
|
|
245
|
+
return run
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def refresh(resource_id, provider, *, min_interval=0):
|
|
249
|
+
run_id, config = await start_run(resource_id, provider, min_interval=min_interval)
|
|
250
|
+
try:
|
|
251
|
+
records = await asyncio.wait_for(collect(provider, config), timeout=MAX_SECONDS)
|
|
252
|
+
except (DiscoveryError, omada.OmadaError) as exc:
|
|
253
|
+
return await finish_run(run_id, error=str(exc))
|
|
254
|
+
except Exception: # noqa: BLE001 — source failures must persist a safe error, never raw credentials.
|
|
255
|
+
# SSH errors and upstream payloads can contain credentials or other unrelated data.
|
|
256
|
+
return await finish_run(run_id, error='Discovery failed or timed out. Check source connectivity and access.')
|
|
257
|
+
return await finish_run(run_id, records)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def matches(observation, data):
|
|
261
|
+
"""All plausible matches need review, including unique IPs (DHCP can reassign them)."""
|
|
262
|
+
observed = observation.data
|
|
263
|
+
reasons = {}
|
|
264
|
+
def add(asset_id, reason):
|
|
265
|
+
reasons.setdefault(asset_id, set()).add(reason)
|
|
266
|
+
for asset in data['assets']:
|
|
267
|
+
if asset.name.casefold() == observed['name'].casefold():
|
|
268
|
+
add(asset.id, 'name')
|
|
269
|
+
for iface in data['interfaces']:
|
|
270
|
+
if observed['mac'] and mac(iface.mac) == observed['mac']:
|
|
271
|
+
add(iface.asset_id, 'MAC')
|
|
272
|
+
for allocation in data['allocations']:
|
|
273
|
+
iface = data['interface_map'].get(allocation.interface_id)
|
|
274
|
+
if iface and allocation.address in observed['addresses']:
|
|
275
|
+
add(iface.asset_id, 'IP (unverified)')
|
|
276
|
+
return [{'asset': data['asset_map'][key], 'reasons': ', '.join(sorted(value))}
|
|
277
|
+
for key, value in sorted(reasons.items(), key=lambda x: data['asset_map'][x[0]].name.casefold())]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Live updates for the admin UI.
|
|
2
|
+
|
|
3
|
+
The server runs one worker against one SQLite file, so fan-out is a set of asyncio queues: the
|
|
4
|
+
approval path publishes a small fact ("request req_x was decided"), each open dashboard turns it
|
|
5
|
+
into markup. Nothing here may block or fail a decision, so publishing is synchronous, non-blocking,
|
|
6
|
+
and lossy in exactly one way: a browser that stops draining is told to reload the whole region
|
|
7
|
+
rather than allowed to back the queue up behind a human tapping approve on their phone.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("keywright.events")
|
|
19
|
+
|
|
20
|
+
QUEUE_MAX = 64
|
|
21
|
+
RESYNC = {"type": "resync"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Subscriber:
|
|
25
|
+
"""One open dashboard."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_MAX)
|
|
29
|
+
self._stale = False
|
|
30
|
+
|
|
31
|
+
def offer(self, event: dict[str, Any]) -> None:
|
|
32
|
+
if self._stale:
|
|
33
|
+
return # a resync is already waiting: it will carry whatever this event said
|
|
34
|
+
try:
|
|
35
|
+
self._queue.put_nowait(event)
|
|
36
|
+
except asyncio.QueueFull:
|
|
37
|
+
self._stale = True
|
|
38
|
+
while not self._queue.empty():
|
|
39
|
+
self._queue.get_nowait()
|
|
40
|
+
self._queue.put_nowait(RESYNC)
|
|
41
|
+
|
|
42
|
+
async def get(self) -> dict[str, Any]:
|
|
43
|
+
event = await self._queue.get()
|
|
44
|
+
if event is RESYNC:
|
|
45
|
+
self._stale = False
|
|
46
|
+
return event
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def waiting(self) -> int:
|
|
50
|
+
return self._queue.qsize()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_subscribers: set[Subscriber] = set()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def publish(type: str, **fields: Any) -> None:
|
|
57
|
+
"""Announce a fact to every open dashboard. Safe to call from anywhere: it never awaits, never
|
|
58
|
+
raises, and never touches the database."""
|
|
59
|
+
if not _subscribers:
|
|
60
|
+
return
|
|
61
|
+
event = {"type": type, **fields}
|
|
62
|
+
for sub in _subscribers:
|
|
63
|
+
sub.offer(event)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextlib.asynccontextmanager
|
|
67
|
+
async def subscribe() -> AsyncIterator[Subscriber]:
|
|
68
|
+
sub = Subscriber()
|
|
69
|
+
_subscribers.add(sub)
|
|
70
|
+
log.debug("dashboard subscribed (%d open)", len(_subscribers))
|
|
71
|
+
try:
|
|
72
|
+
yield sub
|
|
73
|
+
finally:
|
|
74
|
+
_subscribers.discard(sub)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def subscriber_count() -> int:
|
|
78
|
+
return len(_subscribers)
|