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
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Manual infrastructure inventory, independent of authorization and live discovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ipaddress
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select, text
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
DiscoveryLink,
|
|
12
|
+
Resource,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
InventoryAllocation as Allocation,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
InventoryAsset as Asset,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
InventoryInterface as Interface,
|
|
22
|
+
)
|
|
23
|
+
from .models import (
|
|
24
|
+
InventoryResourceLink as ResourceLink,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
InventorySubnet as Subnet,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Stable persisted IDs, shared by forms, discovery review and the MCP schema.
|
|
31
|
+
KINDS = {
|
|
32
|
+
"physical": "Physical host", "vm": "Virtual machine", "container": "Container",
|
|
33
|
+
"network": "Network device", "device": "Other device", "service": "Service",
|
|
34
|
+
"router": "Router / gateway", "switch": "Network switch",
|
|
35
|
+
"access_point": "Wi-Fi access point", "modem": "Modem / ONT",
|
|
36
|
+
"firewall": "Firewall", "network_bridge": "Network bridge / extender",
|
|
37
|
+
"smart_hub": "Smart-home hub / controller", "zigbee_coordinator": "Zigbee coordinator",
|
|
38
|
+
"zwave_controller": "Z-Wave controller", "thread_router": "Thread border router",
|
|
39
|
+
"desktop": "Desktop computer", "laptop": "Laptop", "server": "Server",
|
|
40
|
+
"nas": "NAS / network storage", "phone": "Phone", "tablet": "Tablet",
|
|
41
|
+
"watch": "Smartwatch / wearable", "printer": "Printer", "scanner": "Scanner",
|
|
42
|
+
"smart_plug": "Smart plug / outlet", "power_strip": "Smart power strip",
|
|
43
|
+
"relay": "Smart relay", "light_switch": "Smart light switch",
|
|
44
|
+
"dimmer": "Dimmer", "light_bulb": "Smart light bulb", "light_strip": "LED light strip",
|
|
45
|
+
"light_fixture": "Smart light fixture / lamp", "lighting_controller": "Lighting controller",
|
|
46
|
+
"thermostat": "Thermostat", "smart_vent": "Smart vent / HVAC damper",
|
|
47
|
+
"hvac": "HVAC / air conditioner / heat pump", "heater": "Heater / radiator valve",
|
|
48
|
+
"fan": "Fan / ceiling fan", "air_purifier": "Air purifier",
|
|
49
|
+
"humidifier": "Humidifier", "dehumidifier": "Dehumidifier",
|
|
50
|
+
"camera": "Camera / security camera", "doorbell": "Video doorbell",
|
|
51
|
+
"smart_lock": "Smart door lock", "garage_door": "Garage door / gate opener",
|
|
52
|
+
"alarm": "Alarm panel / security system", "siren": "Siren / chime",
|
|
53
|
+
"motion_sensor": "Motion sensor", "presence_sensor": "Presence / occupancy sensor",
|
|
54
|
+
"contact_sensor": "Door / window contact sensor", "temperature_sensor": "Temperature / humidity sensor",
|
|
55
|
+
"leak_sensor": "Water leak sensor", "smoke_detector": "Smoke detector",
|
|
56
|
+
"co_detector": "Carbon monoxide detector", "air_quality_sensor": "Air quality / CO2 sensor",
|
|
57
|
+
"light_sensor": "Light / illuminance sensor", "weather_station": "Weather station",
|
|
58
|
+
"sensor": "Other sensor", "button": "Smart button / remote control",
|
|
59
|
+
"blinds": "Blinds / shades / curtains", "tv": "TV / projector",
|
|
60
|
+
"streamer": "Streaming player / set-top box", "speaker": "Smart speaker",
|
|
61
|
+
"audio": "Amplifier / receiver / soundbar", "smart_display": "Smart display",
|
|
62
|
+
"game_console": "Game console", "vr_headset": "VR headset",
|
|
63
|
+
"vacuum": "Robot vacuum / mop", "lawn_mower": "Robot lawn mower",
|
|
64
|
+
"refrigerator": "Refrigerator / freezer", "oven": "Oven / stove / microwave",
|
|
65
|
+
"dishwasher": "Dishwasher", "washer": "Washing machine", "dryer": "Clothes dryer",
|
|
66
|
+
"coffee_maker": "Coffee maker / kettle", "appliance": "Other smart appliance",
|
|
67
|
+
"pet_feeder": "Pet feeder / fountain", "litter_box": "Smart litter box",
|
|
68
|
+
"irrigation": "Irrigation / sprinkler controller", "pool_controller": "Pool / spa controller",
|
|
69
|
+
"water_valve": "Water valve / shutoff", "water_heater": "Water heater",
|
|
70
|
+
"energy_meter": "Energy meter / power monitor", "ev_charger": "EV charger",
|
|
71
|
+
"solar_inverter": "Solar inverter", "battery": "Battery / home energy storage",
|
|
72
|
+
"ups": "UPS / battery backup", "fitness": "Fitness equipment / exercise bike",
|
|
73
|
+
"scale": "Smart scale", "bed": "Smart bed / sleep monitor",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
MEDIA = {"unknown": "Unknown", "wired": "Wired", "wifi": "Wi-Fi", "virtual": "Virtual"}
|
|
77
|
+
STATES = {"assigned": "Assigned", "reserved": "Reserved", "planned": "Planned", "excluded": "Excluded"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InventoryError(ValueError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def value(data, key, maximum=160, required=False):
|
|
85
|
+
result = str(data.get(key, "")).strip()
|
|
86
|
+
if required and not result:
|
|
87
|
+
raise InventoryError(f"{key.replace('_', ' ').capitalize()} is required.")
|
|
88
|
+
if len(result) > maximum or any(ord(c) < 32 and c not in '\n\t\r' for c in result):
|
|
89
|
+
raise InventoryError(f"{key.replace('_', ' ').capitalize()} must be at most {maximum} characters.")
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def choice(data, key, options):
|
|
94
|
+
result = value(data, key)
|
|
95
|
+
if result not in options:
|
|
96
|
+
raise InventoryError(f"Choose a valid {key}.")
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def address(value):
|
|
101
|
+
try:
|
|
102
|
+
if '%' in value:
|
|
103
|
+
raise ValueError
|
|
104
|
+
return ipaddress.ip_address(value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise InventoryError("Enter a valid IPv4 or IPv6 address without a zone or prefix.") from None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def bounds(network):
|
|
110
|
+
"""Usable host bounds, without enumerating even very large IPv6 networks."""
|
|
111
|
+
start, end = int(network.network_address), int(network.broadcast_address)
|
|
112
|
+
if network.version == 4 and network.prefixlen < 31:
|
|
113
|
+
start, end = start + 1, end - 1
|
|
114
|
+
elif network.version == 6 and network.prefixlen < 127:
|
|
115
|
+
start += 1 # subnet-router anycast
|
|
116
|
+
return start, end
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def host_address(raw, network):
|
|
120
|
+
ip = address(raw)
|
|
121
|
+
lo, hi = bounds(network)
|
|
122
|
+
if ip.version != network.version or not lo <= int(ip) <= hi:
|
|
123
|
+
raise InventoryError("Address must be a usable host address inside this subnet.")
|
|
124
|
+
return str(ip)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def require(db, model, row_id):
|
|
128
|
+
row = await db.get(model, row_id)
|
|
129
|
+
if row is None:
|
|
130
|
+
raise InventoryError("That record no longer exists. Refresh and try again.")
|
|
131
|
+
return row
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def lock_writes(db):
|
|
135
|
+
# Serialize parent-cycle and subnet-overlap checks with writes, including multiple workers.
|
|
136
|
+
if db.bind.dialect.name == "sqlite":
|
|
137
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
138
|
+
else:
|
|
139
|
+
for model in (Asset, Subnet):
|
|
140
|
+
await db.execute(select(model).with_for_update())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def save_asset(db, data):
|
|
144
|
+
row_id = value(data, "id")
|
|
145
|
+
row = await require(db, Asset, row_id) if row_id else Asset()
|
|
146
|
+
name = value(data, "name", required=True)
|
|
147
|
+
other = await db.scalar(select(Asset).where(Asset.name == name))
|
|
148
|
+
if other and other.id != row_id:
|
|
149
|
+
raise InventoryError("An infrastructure record with this name already exists.")
|
|
150
|
+
kind = choice(data, "kind", KINDS)
|
|
151
|
+
parent_id = value(data, "parent_id") or None
|
|
152
|
+
cursor, visited = parent_id, set()
|
|
153
|
+
while cursor:
|
|
154
|
+
if cursor == row_id or cursor in visited:
|
|
155
|
+
raise InventoryError("A host relationship cannot contain a cycle.")
|
|
156
|
+
visited.add(cursor)
|
|
157
|
+
cursor = (await require(db, Asset, cursor)).parent_id
|
|
158
|
+
row.name, row.kind, row.parent_id = name, kind, parent_id
|
|
159
|
+
row.site, row.tags = value(data, "site"), value(data, "tags", 500)
|
|
160
|
+
row.notes = value(data, "notes", 4000)
|
|
161
|
+
db.add(row)
|
|
162
|
+
await db.flush()
|
|
163
|
+
return row
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def save_interface(db, data):
|
|
167
|
+
row_id, asset_id = value(data, "id"), value(data, "asset_id", required=True)
|
|
168
|
+
await require(db, Asset, asset_id)
|
|
169
|
+
row = await require(db, Interface, row_id) if row_id else Interface(asset_id=asset_id)
|
|
170
|
+
if row.asset_id != asset_id:
|
|
171
|
+
raise InventoryError("This interface belongs to a different infrastructure record.")
|
|
172
|
+
name = value(data, "name", required=True)
|
|
173
|
+
other = await db.scalar(select(Interface).where(Interface.asset_id == asset_id, Interface.name == name))
|
|
174
|
+
if other and other.id != row_id:
|
|
175
|
+
raise InventoryError("This host already has an interface with that name.")
|
|
176
|
+
mac = value(data, "mac").lower().replace('-', ':')
|
|
177
|
+
if mac and not re.fullmatch(r'(?:[0-9a-f]{2}:){5}[0-9a-f]{2}', mac):
|
|
178
|
+
raise InventoryError("Use a MAC address such as 00:11:22:33:44:55.")
|
|
179
|
+
attached = value(data, "attached_to_id") or None
|
|
180
|
+
if attached:
|
|
181
|
+
await require(db, Asset, attached)
|
|
182
|
+
if attached == asset_id:
|
|
183
|
+
raise InventoryError("An interface cannot connect to its own host.")
|
|
184
|
+
row.name, row.medium, row.mac = name, choice(data, "medium", MEDIA), mac
|
|
185
|
+
row.attached_to_id, row.port = attached, value(data, "port")
|
|
186
|
+
db.add(row)
|
|
187
|
+
await db.flush()
|
|
188
|
+
return row
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def save_subnet(db, data):
|
|
192
|
+
row_id = value(data, "id")
|
|
193
|
+
row = await require(db, Subnet, row_id) if row_id else Subnet()
|
|
194
|
+
try:
|
|
195
|
+
network = ipaddress.ip_network(value(data, "cidr", required=True), strict=True)
|
|
196
|
+
except ValueError:
|
|
197
|
+
raise InventoryError("Enter a network in CIDR notation, with no host bits (e.g. 192.168.1.0/24).") from None
|
|
198
|
+
scope = value(data, "scope") or "default"
|
|
199
|
+
for other in (await db.scalars(select(Subnet).where(Subnet.scope == scope))).all():
|
|
200
|
+
if other.id != row_id and network.overlaps(ipaddress.ip_network(other.cidr)):
|
|
201
|
+
raise InventoryError("This network overlaps another subnet in the same address space.")
|
|
202
|
+
vlan = value(data, "vlan")
|
|
203
|
+
if vlan and (not vlan.isascii() or not vlan.isdigit() or not 1 <= int(vlan) <= 4094):
|
|
204
|
+
raise InventoryError("VLAN must be between 1 and 4094, or left blank.")
|
|
205
|
+
gateway = value(data, "gateway")
|
|
206
|
+
if gateway:
|
|
207
|
+
gateway = host_address(gateway, network)
|
|
208
|
+
start, end = value(data, "dhcp_start"), value(data, "dhcp_end")
|
|
209
|
+
if bool(start) != bool(end):
|
|
210
|
+
raise InventoryError("Specify both DHCP pool endpoints, or leave both blank.")
|
|
211
|
+
if start:
|
|
212
|
+
start, end = host_address(start, network), host_address(end, network)
|
|
213
|
+
if int(address(start)) > int(address(end)):
|
|
214
|
+
raise InventoryError("DHCP pool start must precede its end.")
|
|
215
|
+
if gateway and int(address(start)) <= int(address(gateway)) <= int(address(end)):
|
|
216
|
+
raise InventoryError("The gateway cannot be inside the DHCP pool.")
|
|
217
|
+
allocations = (await db.scalars(select(Allocation).where(Allocation.subnet_id == row_id))).all() if row_id else []
|
|
218
|
+
for allocation in allocations:
|
|
219
|
+
host_address(allocation.address, network)
|
|
220
|
+
if allocation.address == gateway:
|
|
221
|
+
raise InventoryError("The gateway already has an allocation. Keep the allocation or the gateway entry.")
|
|
222
|
+
row.name, row.cidr, row.scope = value(data, "name", required=True), str(network), scope
|
|
223
|
+
row.site, row.vlan = value(data, "site"), int(vlan) if vlan else None
|
|
224
|
+
row.gateway, row.dhcp_start, row.dhcp_end = gateway, start, end
|
|
225
|
+
row.notes = value(data, "notes", 4000)
|
|
226
|
+
db.add(row)
|
|
227
|
+
await db.flush()
|
|
228
|
+
return row
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def save_allocation(db, data):
|
|
232
|
+
row_id, subnet_id = value(data, "id"), value(data, "subnet_id", required=True)
|
|
233
|
+
subnet = await require(db, Subnet, subnet_id)
|
|
234
|
+
row = await require(db, Allocation, row_id) if row_id else Allocation(subnet_id=subnet_id)
|
|
235
|
+
if row.subnet_id != subnet_id:
|
|
236
|
+
raise InventoryError("This allocation belongs to a different subnet.")
|
|
237
|
+
ip = host_address(value(data, "address", required=True), ipaddress.ip_network(subnet.cidr))
|
|
238
|
+
if ip == subnet.gateway:
|
|
239
|
+
raise InventoryError("This address is already recorded as the subnet gateway.")
|
|
240
|
+
other = await db.scalar(select(Allocation).where(Allocation.subnet_id == subnet_id, Allocation.address == ip))
|
|
241
|
+
if other and other.id != row_id:
|
|
242
|
+
raise InventoryError("This address already has an allocation in this subnet.")
|
|
243
|
+
state, interface_id = choice(data, "state", STATES), value(data, "interface_id") or None
|
|
244
|
+
if interface_id:
|
|
245
|
+
await require(db, Interface, interface_id)
|
|
246
|
+
if state == "assigned" and not interface_id:
|
|
247
|
+
raise InventoryError("An assigned address must be linked to an interface.")
|
|
248
|
+
label = value(data, "label")
|
|
249
|
+
if not interface_id and not label:
|
|
250
|
+
raise InventoryError("Add a label or choose an interface so this allocation has an owner or purpose.")
|
|
251
|
+
row.address, row.state, row.interface_id = ip, state, interface_id
|
|
252
|
+
row.label, row.notes = label, value(data, "notes", 4000)
|
|
253
|
+
db.add(row)
|
|
254
|
+
await db.flush()
|
|
255
|
+
return row
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def link_resource(db, data):
|
|
259
|
+
resource_id, asset_id = value(data, "resource_id", required=True), value(data, "asset_id", required=True)
|
|
260
|
+
await require(db, Resource, resource_id)
|
|
261
|
+
await require(db, Asset, asset_id)
|
|
262
|
+
row = await db.get(ResourceLink, resource_id)
|
|
263
|
+
if row and row.asset_id != asset_id:
|
|
264
|
+
raise InventoryError("This resource is already linked to another host. Unlink it there first.")
|
|
265
|
+
if not row:
|
|
266
|
+
row = ResourceLink(resource_id=resource_id, asset_id=asset_id)
|
|
267
|
+
db.add(row)
|
|
268
|
+
await db.flush()
|
|
269
|
+
return row
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def delete_record(db, kind, row_id):
|
|
273
|
+
models = {"asset": Asset, "interface": Interface, "subnet": Subnet, "allocation": Allocation,
|
|
274
|
+
"link": ResourceLink}
|
|
275
|
+
if kind not in models:
|
|
276
|
+
raise InventoryError("Unknown inventory record type.")
|
|
277
|
+
row = await require(db, models[kind], row_id)
|
|
278
|
+
checks = {
|
|
279
|
+
"asset": [(Asset, Asset.parent_id), (Interface, Interface.asset_id),
|
|
280
|
+
(Interface, Interface.attached_to_id), (ResourceLink, ResourceLink.asset_id),
|
|
281
|
+
(DiscoveryLink, DiscoveryLink.asset_id)],
|
|
282
|
+
"interface": [(Allocation, Allocation.interface_id), (DiscoveryLink, DiscoveryLink.interface_id)],
|
|
283
|
+
"subnet": [(Allocation, Allocation.subnet_id)],
|
|
284
|
+
}
|
|
285
|
+
for model, field in checks.get(kind, []):
|
|
286
|
+
if await db.scalar(select(model).where(field == row_id).limit(1)):
|
|
287
|
+
raise InventoryError("This record is still in use. Remove or reassign its links and allocations first.")
|
|
288
|
+
await db.delete(row)
|
|
289
|
+
return row
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def snapshot(db):
|
|
293
|
+
result = {}
|
|
294
|
+
for key, model in (("assets", Asset), ("interfaces", Interface), ("subnets", Subnet),
|
|
295
|
+
("allocations", Allocation), ("links", ResourceLink), ("resources", Resource)):
|
|
296
|
+
result[key] = list((await db.scalars(select(model))).all())
|
|
297
|
+
result["assets"].sort(key=lambda x: x.name.casefold())
|
|
298
|
+
result["subnets"].sort(key=lambda x: (x.scope, address_key(x.cidr.split('/')[0])))
|
|
299
|
+
result["resources"].sort(key=lambda x: x.name.casefold())
|
|
300
|
+
result["asset_map"] = {x.id: x for x in result["assets"]}
|
|
301
|
+
result["interface_map"] = {x.id: x for x in result["interfaces"]}
|
|
302
|
+
result["subnet_map"] = {x.id: x for x in result["subnets"]}
|
|
303
|
+
result["resource_map"] = {x.id: x for x in result["resources"]}
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def address_key(ip):
|
|
308
|
+
if not ip:
|
|
309
|
+
return 9, 0
|
|
310
|
+
parsed = address(ip)
|
|
311
|
+
return parsed.version, int(parsed)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def asset_rows(data, query="", sort="name", group="none"):
|
|
315
|
+
interfaces, allocations, links = defaultdict(list), defaultdict(list), defaultdict(list)
|
|
316
|
+
for row in data["interfaces"]:
|
|
317
|
+
interfaces[row.asset_id].append(row)
|
|
318
|
+
for row in data["allocations"]:
|
|
319
|
+
interface = data["interface_map"].get(row.interface_id)
|
|
320
|
+
if interface:
|
|
321
|
+
allocations[interface.asset_id].append(row)
|
|
322
|
+
for row in data["links"]:
|
|
323
|
+
if row.resource_id in data["resource_map"]:
|
|
324
|
+
links[row.asset_id].append(data["resource_map"][row.resource_id])
|
|
325
|
+
rows = []
|
|
326
|
+
for asset in data["assets"]:
|
|
327
|
+
ips = sorted(allocations[asset.id], key=lambda a: address_key(a.address))
|
|
328
|
+
networks = sorted({data["subnet_map"][a.subnet_id].name for a in ips})
|
|
329
|
+
parent = data["asset_map"].get(asset.parent_id)
|
|
330
|
+
media = sorted({MEDIA[i.medium] for i in interfaces[asset.id]})
|
|
331
|
+
row = {"asset": asset, "interfaces": interfaces[asset.id], "allocations": ips,
|
|
332
|
+
"parent": parent, "media": media, "networks": networks, "resources": links[asset.id]}
|
|
333
|
+
haystack = ' '.join([asset.name, KINDS[asset.kind], asset.site, asset.tags,
|
|
334
|
+
parent.name if parent else '', *media, *networks,
|
|
335
|
+
*(a.address for a in ips), *(r.name for r in links[asset.id])])
|
|
336
|
+
if query.casefold() not in haystack.casefold():
|
|
337
|
+
continue
|
|
338
|
+
row["group"] = {"host": parent.name if parent else "No parent host", "site": asset.site or "No site",
|
|
339
|
+
"kind": KINDS[asset.kind], "network": ', '.join(networks) or "No network",
|
|
340
|
+
"medium": ', '.join(media) or "Unknown"}.get(group, "Infrastructure")
|
|
341
|
+
rows.append(row)
|
|
342
|
+
def key(row):
|
|
343
|
+
a = row["asset"]
|
|
344
|
+
primary = {"name": a.name.casefold(), "ip": address_key(row["allocations"][0].address if row["allocations"] else ''),
|
|
345
|
+
"kind": KINDS[a.kind], "site": a.site.casefold()}.get(sort, a.name.casefold())
|
|
346
|
+
return row["group"].casefold(), primary, a.name.casefold()
|
|
347
|
+
return sorted(rows, key=key)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def hosting_tree(data):
|
|
351
|
+
"""An iterative outline avoids recursion limits for imported or deep hierarchies."""
|
|
352
|
+
children = defaultdict(list)
|
|
353
|
+
for asset in data["assets"]:
|
|
354
|
+
children[asset.parent_id if asset.parent_id in data["asset_map"] else None].append(asset)
|
|
355
|
+
rows, visited = [], set()
|
|
356
|
+
stack = [(asset, 0, []) for asset in reversed(children[None])]
|
|
357
|
+
while stack:
|
|
358
|
+
asset, depth, ancestors = stack.pop()
|
|
359
|
+
if asset.id in visited:
|
|
360
|
+
continue
|
|
361
|
+
visited.add(asset.id)
|
|
362
|
+
rows.append({"asset": asset, "depth": depth, "ancestors": ancestors,
|
|
363
|
+
"children": bool(children[asset.id])})
|
|
364
|
+
stack.extend((child, depth + 1, [*ancestors, asset.id]) for child in reversed(children[asset.id]))
|
|
365
|
+
return rows
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def subnet_summary(subnet, allocations):
|
|
369
|
+
network = ipaddress.ip_network(subnet.cidr)
|
|
370
|
+
lo, hi = bounds(network)
|
|
371
|
+
rows = sorted((x for x in allocations if x.subnet_id == subnet.id), key=lambda x: address_key(x.address))
|
|
372
|
+
occupied = [(int(address(x.address)), int(address(x.address))) for x in rows]
|
|
373
|
+
if subnet.gateway:
|
|
374
|
+
occupied.append((int(address(subnet.gateway)), int(address(subnet.gateway))))
|
|
375
|
+
pool_size = 0
|
|
376
|
+
if subnet.dhcp_start:
|
|
377
|
+
start, end = int(address(subnet.dhcp_start)), int(address(subnet.dhcp_end))
|
|
378
|
+
occupied.append((start, end))
|
|
379
|
+
pool_size = end - start + 1
|
|
380
|
+
# Gaps are unrecorded, never proof of availability. Merge intervals rather than enumerate hosts.
|
|
381
|
+
cursor, gaps = lo, []
|
|
382
|
+
for start, end in sorted(occupied):
|
|
383
|
+
if start > cursor:
|
|
384
|
+
gaps.append((cursor, start - 1))
|
|
385
|
+
cursor = max(cursor, end + 1)
|
|
386
|
+
if cursor <= hi:
|
|
387
|
+
gaps.append((cursor, hi))
|
|
388
|
+
constructor = ipaddress.IPv4Address if network.version == 4 else ipaddress.IPv6Address
|
|
389
|
+
return {"rows": rows, "capacity": hi - lo + 1, "recorded": len(rows), "pool_size": pool_size,
|
|
390
|
+
"unrecorded": sum(end - start + 1 for start, end in gaps),
|
|
391
|
+
"gaps": [(str(constructor(start)), str(constructor(end))) for start, end in gaps]}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def resource_inventory(data, sort="type", group="none"):
|
|
395
|
+
assets = {link.resource_id: data["asset_map"].get(link.asset_id) for link in data["links"]}
|
|
396
|
+
details = {row["asset"].id: row for row in asset_rows(data)}
|
|
397
|
+
labels = {}
|
|
398
|
+
def ip_key(resource):
|
|
399
|
+
asset = assets.get(resource.id)
|
|
400
|
+
if asset and details[asset.id]["allocations"]:
|
|
401
|
+
return address_key(details[asset.id]["allocations"][0].address)
|
|
402
|
+
try:
|
|
403
|
+
return address_key(resource.config.get("host", ""))
|
|
404
|
+
except InventoryError:
|
|
405
|
+
return 9, 0
|
|
406
|
+
for resource in data["resources"]:
|
|
407
|
+
asset = assets.get(resource.id)
|
|
408
|
+
detail = details.get(asset.id) if asset else None
|
|
409
|
+
labels[resource.id] = ({"host": asset.name if asset else "Not linked to inventory",
|
|
410
|
+
"network": ', '.join(detail["networks"]) if detail and detail["networks"]
|
|
411
|
+
else "No network recorded"}.get(group, "Resources"))
|
|
412
|
+
rows = sorted(data["resources"], key=lambda r: (
|
|
413
|
+
labels[r.id].casefold(),
|
|
414
|
+
ip_key(r) if sort == "ip" else (r.name.casefold() if sort == "name" else r.kind), r.name.casefold()))
|
|
415
|
+
return {"rows": rows, "resource_assets": assets, "resource_groups": labels, "sort": sort, "group": group}
|