Restore device summary fields and assembly data flow

This commit is contained in:
2025-10-23 04:36:24 -06:00
parent 40cab79f21
commit 0a9a626c56
4 changed files with 166 additions and 43 deletions

View File

@@ -7,6 +7,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Mapping, Optional, Sequence from typing import Any, Dict, List, Mapping, Optional, Sequence
from Data.Engine.domain.device_auth import normalize_guid
__all__ = [ __all__ = [
"DEVICE_TABLE_COLUMNS", "DEVICE_TABLE_COLUMNS",
"DEVICE_TABLE", "DEVICE_TABLE",
@@ -91,8 +93,13 @@ class DeviceSnapshot:
operating_system: str operating_system: str
uptime: int uptime: int
agent_id: str agent_id: str
ansible_ee_ver: str
connection_type: str connection_type: str
connection_endpoint: str connection_endpoint: str
ssl_key_fingerprint: str
token_version: int
status: str
key_added_at: str
details: Dict[str, Any] details: Dict[str, Any]
summary: Dict[str, Any] summary: Dict[str, Any]
@@ -121,8 +128,13 @@ class DeviceSnapshot:
"operating_system": self.operating_system, "operating_system": self.operating_system,
"uptime": self.uptime, "uptime": self.uptime,
"agent_id": self.agent_id, "agent_id": self.agent_id,
"ansible_ee_ver": self.ansible_ee_ver,
"connection_type": self.connection_type, "connection_type": self.connection_type,
"connection_endpoint": self.connection_endpoint, "connection_endpoint": self.connection_endpoint,
"ssl_key_fingerprint": self.ssl_key_fingerprint,
"token_version": self.token_version,
"status": self.status,
"key_added_at": self.key_added_at,
"details": self.details, "details": self.details,
"summary": self.summary, "summary": self.summary,
} }
@@ -211,33 +223,16 @@ def row_to_device_dict(row: Sequence[Any], columns: Sequence[str]) -> Dict[str,
def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]: def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]:
summary = { hostname = clean_device_str(record.get("hostname")) or ""
"hostname": record.get("hostname") or "", description = clean_device_str(record.get("description")) or ""
"description": record.get("description") or "", agent_hash = clean_device_str(record.get("agent_hash")) or ""
"device_type": record.get("device_type") or "", raw_guid = clean_device_str(record.get("guid"))
"domain": record.get("domain") or "", normalized_guid = normalize_guid(raw_guid)
"external_ip": record.get("external_ip") or "",
"internal_ip": record.get("internal_ip") or "",
"last_reboot": record.get("last_reboot") or "",
"last_seen": record.get("last_seen") or 0,
"last_user": record.get("last_user") or "",
"operating_system": record.get("operating_system") or "",
"uptime": record.get("uptime") or 0,
"agent_id": record.get("agent_id") or "",
"agent_hash": record.get("agent_hash") or "",
"agent_guid": record.get("guid") or record.get("agent_guid") or "",
"connection_type": record.get("connection_type") or "",
"connection_endpoint": record.get("connection_endpoint") or "",
"ssl_key_fingerprint": record.get("ssl_key_fingerprint") or "",
"status": record.get("status") or "",
"token_version": record.get("token_version") or 0,
"key_added_at": record.get("key_added_at") or "",
"created_at": record.get("created_at") or 0,
}
created_ts = coerce_int(summary.get("created_at")) or 0 created_ts = coerce_int(record.get("created_at")) or 0
last_seen_ts = coerce_int(summary.get("last_seen")) or 0 last_seen_ts = coerce_int(record.get("last_seen")) or 0
uptime_val = coerce_int(summary.get("uptime")) or 0 uptime_val = coerce_int(record.get("uptime")) or 0
token_version = coerce_int(record.get("token_version")) or 0
parsed_lists = { parsed_lists = {
key: _parse_device_json(record.get(key), default) key: _parse_device_json(record.get(key), default)
@@ -245,20 +240,48 @@ def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]:
} }
cpu_obj = _parse_device_json(record.get("cpu"), DEVICE_JSON_OBJECT_FIELDS["cpu"]) cpu_obj = _parse_device_json(record.get("cpu"), DEVICE_JSON_OBJECT_FIELDS["cpu"])
summary: Dict[str, Any] = {
"hostname": hostname,
"description": description,
"agent_hash": agent_hash,
"agent_guid": normalized_guid or "",
"agent_id": clean_device_str(record.get("agent_id")) or "",
"device_type": clean_device_str(record.get("device_type")) or "",
"domain": clean_device_str(record.get("domain")) or "",
"external_ip": clean_device_str(record.get("external_ip")) or "",
"internal_ip": clean_device_str(record.get("internal_ip")) or "",
"last_reboot": clean_device_str(record.get("last_reboot")) or "",
"last_seen": last_seen_ts,
"last_user": clean_device_str(record.get("last_user")) or "",
"operating_system": clean_device_str(record.get("operating_system")) or "",
"uptime": uptime_val,
"uptime_sec": uptime_val,
"ansible_ee_ver": clean_device_str(record.get("ansible_ee_ver")) or "",
"connection_type": clean_device_str(record.get("connection_type")) or "",
"connection_endpoint": clean_device_str(record.get("connection_endpoint")) or "",
"ssl_key_fingerprint": clean_device_str(record.get("ssl_key_fingerprint")) or "",
"status": clean_device_str(record.get("status")) or "",
"token_version": token_version,
"key_added_at": clean_device_str(record.get("key_added_at")) or "",
"created_at": created_ts,
"created": ts_to_human(created_ts),
}
details = { details = {
"memory": parsed_lists["memory"], "memory": parsed_lists["memory"],
"network": parsed_lists["network"], "network": parsed_lists["network"],
"software": parsed_lists["software"], "software": parsed_lists["software"],
"storage": parsed_lists["storage"], "storage": parsed_lists["storage"],
"cpu": cpu_obj, "cpu": cpu_obj,
"summary": dict(summary),
} }
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"hostname": summary["hostname"], "hostname": hostname,
"description": summary.get("description", ""), "description": description,
"created_at": created_ts, "created_at": created_ts,
"created_at_iso": ts_to_iso(created_ts), "created_at_iso": ts_to_iso(created_ts),
"agent_hash": summary.get("agent_hash", ""), "agent_hash": agent_hash,
"agent_guid": summary.get("agent_guid", ""), "agent_guid": summary.get("agent_guid", ""),
"guid": summary.get("agent_guid", ""), "guid": summary.get("agent_guid", ""),
"memory": parsed_lists["memory"], "memory": parsed_lists["memory"],
@@ -277,8 +300,13 @@ def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]:
"operating_system": summary.get("operating_system", ""), "operating_system": summary.get("operating_system", ""),
"uptime": uptime_val, "uptime": uptime_val,
"agent_id": summary.get("agent_id", ""), "agent_id": summary.get("agent_id", ""),
"ansible_ee_ver": summary.get("ansible_ee_ver", ""),
"connection_type": summary.get("connection_type", ""), "connection_type": summary.get("connection_type", ""),
"connection_endpoint": summary.get("connection_endpoint", ""), "connection_endpoint": summary.get("connection_endpoint", ""),
"ssl_key_fingerprint": summary.get("ssl_key_fingerprint", ""),
"token_version": summary.get("token_version", 0),
"status": summary.get("status", ""),
"key_added_at": summary.get("key_added_at", ""),
"details": details, "details": details,
"summary": summary, "summary": summary,
} }

View File

@@ -278,28 +278,60 @@ class SQLiteDeviceInventoryRepository:
for field in ("memory", "network", "software", "storage"): for field in ("memory", "network", "software", "storage"):
payload[field] = serialize_device_json(details.get(field), []) payload[field] = serialize_device_json(details.get(field), [])
payload["cpu"] = serialize_device_json(summary.get("cpu") or details.get("cpu"), {}) payload["cpu"] = serialize_device_json(summary.get("cpu") or details.get("cpu"), {})
payload["device_type"] = clean_device_str(summary.get("device_type") or summary.get("type")) payload["device_type"] = clean_device_str(
payload["domain"] = clean_device_str(summary.get("domain")) summary.get("device_type")
payload["external_ip"] = clean_device_str(summary.get("external_ip") or summary.get("public_ip")) or summary.get("type")
payload["internal_ip"] = clean_device_str(summary.get("internal_ip") or summary.get("private_ip")) or summary.get("device_class")
payload["last_reboot"] = clean_device_str(summary.get("last_reboot") or summary.get("last_boot")) )
payload["last_seen"] = coerce_int(summary.get("last_seen")) payload["domain"] = clean_device_str(
summary.get("domain") or summary.get("domain_name")
)
payload["external_ip"] = clean_device_str(
summary.get("external_ip") or summary.get("public_ip")
)
payload["internal_ip"] = clean_device_str(
summary.get("internal_ip") or summary.get("private_ip")
)
payload["last_reboot"] = clean_device_str(
summary.get("last_reboot") or summary.get("last_boot")
)
payload["last_seen"] = coerce_int(
summary.get("last_seen") or summary.get("last_seen_epoch")
)
payload["last_user"] = clean_device_str( payload["last_user"] = clean_device_str(
summary.get("last_user") summary.get("last_user")
or summary.get("last_user_name") or summary.get("last_user_name")
or summary.get("logged_in_user") or summary.get("logged_in_user")
or summary.get("username")
or summary.get("user")
) )
payload["operating_system"] = clean_device_str( payload["operating_system"] = clean_device_str(
summary.get("operating_system") or summary.get("os") summary.get("operating_system")
or summary.get("agent_operating_system")
or summary.get("os")
) )
payload["uptime"] = coerce_int(summary.get("uptime")) uptime_value = (
summary.get("uptime_sec")
or summary.get("uptime_seconds")
or summary.get("uptime")
)
payload["uptime"] = coerce_int(uptime_value)
payload["agent_id"] = clean_device_str(summary.get("agent_id")) payload["agent_id"] = clean_device_str(summary.get("agent_id"))
payload["ansible_ee_ver"] = clean_device_str(summary.get("ansible_ee_ver")) payload["ansible_ee_ver"] = clean_device_str(summary.get("ansible_ee_ver"))
payload["connection_type"] = clean_device_str(summary.get("connection_type")) payload["connection_type"] = clean_device_str(
payload["connection_endpoint"] = clean_device_str( summary.get("connection_type") or summary.get("remote_type")
summary.get("connection_endpoint") or summary.get("endpoint") )
payload["connection_endpoint"] = clean_device_str(
summary.get("connection_endpoint")
or summary.get("endpoint")
or summary.get("connection_address")
or summary.get("address")
or summary.get("external_ip")
or summary.get("internal_ip")
)
payload["ssl_key_fingerprint"] = clean_device_str(
summary.get("ssl_key_fingerprint")
) )
payload["ssl_key_fingerprint"] = clean_device_str(summary.get("ssl_key_fingerprint"))
payload["token_version"] = coerce_int(summary.get("token_version")) or 0 payload["token_version"] = coerce_int(summary.get("token_version")) or 0
payload["status"] = clean_device_str(summary.get("status")) payload["status"] = clean_device_str(summary.get("status"))
payload["key_added_at"] = clean_device_str(summary.get("key_added_at")) payload["key_added_at"] = clean_device_str(summary.get("key_added_at"))

View File

@@ -14,7 +14,7 @@ from Data.Engine.repositories.sqlite.device_inventory_repository import (
SQLiteDeviceInventoryRepository, SQLiteDeviceInventoryRepository,
) )
from Data.Engine.domain.device_auth import DeviceAuthContext, normalize_guid from Data.Engine.domain.device_auth import DeviceAuthContext, normalize_guid
from Data.Engine.domain.devices import clean_device_str, coerce_int from Data.Engine.domain.devices import clean_device_str, coerce_int, ts_to_human
__all__ = [ __all__ = [
"DeviceInventoryService", "DeviceInventoryService",
@@ -240,7 +240,7 @@ class DeviceInventoryService:
summary["hostname"] = hostname summary["hostname"] = hostname
if metrics: if metrics:
last_user = metrics.get("last_user") last_user = metrics.get("last_user") or metrics.get("username") or metrics.get("user")
if last_user: if last_user:
cleaned_user = clean_device_str(last_user) cleaned_user = clean_device_str(last_user)
if cleaned_user: if cleaned_user:
@@ -422,6 +422,8 @@ class DeviceInventoryService:
if created_at is None: if created_at is None:
created_at = int(time.time()) created_at = int(time.time())
merged_summary["created_at"] = created_at merged_summary["created_at"] = created_at
if not merged_summary.get("created"):
merged_summary["created"] = ts_to_human(created_at)
if fingerprint: if fingerprint:
merged_summary["ssl_key_fingerprint"] = fingerprint merged_summary["ssl_key_fingerprint"] = fingerprint
@@ -431,6 +433,14 @@ class DeviceInventoryService:
merged_summary["token_version"] = 1 merged_summary["token_version"] = 1
if not merged_summary.get("status") and snapshot.get("summary", {}).get("status"): if not merged_summary.get("status") and snapshot.get("summary", {}).get("status"):
merged_summary["status"] = snapshot.get("summary", {}).get("status") merged_summary["status"] = snapshot.get("summary", {}).get("status")
uptime_val = merged_summary.get("uptime")
if merged_summary.get("uptime_sec") is None and uptime_val is not None:
coerced = coerce_int(uptime_val)
if coerced is not None:
merged_summary["uptime_sec"] = coerced
merged_summary.setdefault("uptime_seconds", coerced)
if merged_summary.get("uptime_seconds") is None and merged_summary.get("uptime_sec") is not None:
merged_summary["uptime_seconds"] = merged_summary.get("uptime_sec")
description = clean_device_str(merged_summary.get("description")) description = clean_device_str(merged_summary.get("description"))
existing_description = snapshot.get("description") if snapshot else "" existing_description = snapshot.get("description") if snapshot else ""

View File

@@ -255,10 +255,14 @@ def test_agent_details_persists_inventory(prepared_app, monkeypatch):
"last_user": "BUNNY-LAB\\nicole.rappe", "last_user": "BUNNY-LAB\\nicole.rappe",
"operating_system": "Windows 11", "operating_system": "Windows 11",
"description": "Primary workstation", "description": "Primary workstation",
"last_reboot": "2025-10-01 10:00:00",
"uptime": 3600,
}, },
"memory": [{"slot": "DIMM0", "capacity": 17179869184}], "memory": [{"slot": "DIMM0", "capacity": 17179869184}],
"storage": [{"model": "NVMe", "size": 512}], "storage": [{"model": "NVMe", "size": 512}],
"network": [{"adapter": "Ethernet", "ips": ["192.168.1.50"]}], "network": [{"adapter": "Ethernet", "ips": ["192.168.1.50"]}],
"software": [{"name": "Borealis Agent", "version": "2.0"}],
"cpu": {"name": "Intel Core i7", "logical_cores": 8, "base_clock_ghz": 3.4},
}, },
} }
@@ -291,6 +295,26 @@ def test_agent_details_persists_inventory(prepared_app, monkeypatch):
assert json.loads(storage_json)[0]["model"] == "NVMe" assert json.loads(storage_json)[0]["model"] == "NVMe"
assert json.loads(network_json)[0]["ips"][0] == "192.168.1.50" assert json.loads(network_json)[0]["ips"][0] == "192.168.1.50"
resp = client.get("/api/devices")
assert resp.status_code == 200
listing = resp.get_json()
device = next((dev for dev in listing.get("devices", []) if dev["hostname"] == hostname), None)
assert device is not None
summary = device["summary"]
details = device["details"]
assert summary["device_type"] == "Laptop"
assert summary["last_user"] == "BUNNY-LAB\\nicole.rappe"
assert summary["created"]
assert summary.get("uptime_sec") == 3600
assert details["summary"]["device_type"] == "Laptop"
assert details["summary"]["last_reboot"] == "2025-10-01 10:00:00"
assert details["summary"]["created"] == summary["created"]
assert details["software"][0]["name"] == "Borealis Agent"
assert device["storage"][0]["model"] == "NVMe"
assert device["memory"][0]["capacity"] == 17179869184
assert device["cpu"]["name"] == "Intel Core i7"
def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch): def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch):
client = prepared_app.test_client() client = prepared_app.test_client()
@@ -330,3 +354,32 @@ def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch):
assert row is not None assert row is not None
assert row[0] == "BUNNY-LAB\\nicole.rappe" assert row[0] == "BUNNY-LAB\\nicole.rappe"
def test_heartbeat_uses_username_when_last_user_missing(prepared_app, monkeypatch):
client = prepared_app.test_client()
guid = "802A4E5F-1B2C-4D5E-8F90-A1B2C3D4E5F7"
fingerprint = "55:66:77:88"
hostname = "device-username"
_insert_device(prepared_app, guid, fingerprint, hostname)
services = prepared_app.extensions["engine_services"]
context = _build_context(guid, fingerprint)
monkeypatch.setattr(services.device_auth, "authenticate", lambda request, path: context)
resp = client.post(
"/api/agent/heartbeat",
json={"hostname": hostname, "metrics": {"username": "BUNNY-LAB\\alice.smith"}},
headers={"Authorization": "Bearer token"},
)
assert resp.status_code == 200
db_path = Path(prepared_app.config["ENGINE_DATABASE_PATH"])
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"SELECT last_user FROM devices WHERE guid = ?",
(guid,),
).fetchone()
assert row is not None
assert row[0] == "BUNNY-LAB\\alice.smith"