refactor(fleet): separate naming, routes, and snapshot writes
This commit is contained in:
8 files changed
+663
-411
No files matched your search
@@ -75,6 +75,14 @@ Current DHCP leases are summary-eligible and may support wake if they include a
|
|||||||
|
|
||||||
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
|
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
|
||||||
|
|
||||||
|
Fleet display labels prefer the saved device name, then a hostname, then a MAC,
|
||||||
|
then an IP. Generated address labels use `Device: <address>` without presence
|
||||||
|
prefixes; the badge carries presence. An inactive IP is only a naming fallback
|
||||||
|
when no name or MAC exists, never evidence of a current address or a wake route.
|
||||||
|
Offline neighbor evidence does not establish that a DHCP address has expired or
|
||||||
|
been reassigned. If a concrete source observes that endpoint again, its eligible
|
||||||
|
IP returns to the summary; manually remembered identifiers are not removed by this.
|
||||||
|
|
||||||
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
|
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
|
||||||
|
|
||||||
The control plane derives stored snapshot presence and fleet presence from the
|
The control plane derives stored snapshot presence and fleet presence from the
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ live browser recovery and shutdown behavior are not established by those checks.
|
|||||||
- [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
|
- [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
|
||||||
- [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
|
- [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
|
||||||
- [ ] Check and complete editing of remembered-device notes.
|
- [ ] Check and complete editing of remembered-device notes.
|
||||||
- [x] Replace generic unnamed-row labels with useful fallbacks. Offline IP-only rows: `Offline device: <ip>`; unknown-presence IP-only rows: `Device: <ip>`. Use MAC fallback when no IP exists; retain a generic label only when neither exists. Inactive endpoint addresses can label a row without becoming active IPs or wake routes. Unknown-only fleet groups remain unknown.
|
- [x] Replace generic unnamed-row labels with useful fallbacks. Prefer saved name, hostname, then `Device: <mac>`, then `Device: <ip>`; retain a generic label only when neither address exists. Presence stays in the badge, not the name. Inactive IPs can label IP-only rows without becoming active IPs or wake routes. Unknown-only fleet groups remain unknown.
|
||||||
- [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying.
|
- [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying.
|
||||||
|
|
||||||
Identifier detachment already exists in the current frontend source; it is **not**
|
Identifier detachment already exists in the current frontend source; it is **not**
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use wakey_core::{EndpointSource, Presence};
|
use wakey_core::Presence;
|
||||||
|
|
||||||
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
||||||
|
|
||||||
|
mod addresses;
|
||||||
|
mod routes;
|
||||||
|
use addresses::add_address_evidence;
|
||||||
|
use routes::{add_endpoint_routes, add_legacy_routes, compare_route_quality};
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
|
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
|
||||||
};
|
};
|
||||||
@@ -26,7 +31,9 @@ pub(crate) struct AgentRuntimeStatus {
|
|||||||
struct FleetAccumulator {
|
struct FleetAccumulator {
|
||||||
device_key: String,
|
device_key: String,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
label_ip: Option<IpAddr>,
|
/// Naming-only evidence, used when no name, MAC, or summary IP is available.
|
||||||
|
/// Must not contribute to summary IPs or wake routes.
|
||||||
|
fallback_ip: Option<IpAddr>,
|
||||||
known_device: Option<KnownDeviceSummary>,
|
known_device: Option<KnownDeviceSummary>,
|
||||||
pinned: bool,
|
pinned: bool,
|
||||||
ips: BTreeSet<IpAddr>,
|
ips: BTreeSet<IpAddr>,
|
||||||
@@ -46,7 +53,7 @@ impl Default for FleetAccumulator {
|
|||||||
Self {
|
Self {
|
||||||
device_key: String::new(),
|
device_key: String::new(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
label_ip: None,
|
fallback_ip: None,
|
||||||
known_device: None,
|
known_device: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
ips: BTreeSet::new(),
|
ips: BTreeSet::new(),
|
||||||
@@ -197,23 +204,22 @@ fn add_agent_device_to_entry(
|
|||||||
agent_device: AgentDeviceWithChildren,
|
agent_device: AgentDeviceWithChildren,
|
||||||
context: &FleetBuildContext,
|
context: &FleetBuildContext,
|
||||||
) {
|
) {
|
||||||
// Retain address evidence for naming only, without promoting stale IPs into
|
add_address_evidence(entry, &agent_device);
|
||||||
// the active summary or wake-route candidates.
|
add_names_and_identity(entry, &agent_device, context);
|
||||||
let label_ip = agent_device
|
let status = add_agent_summary(entry, &agent_device, context);
|
||||||
.ips
|
if agent_device.endpoints.is_empty() {
|
||||||
.iter()
|
add_legacy_routes(entry, &agent_device, &status);
|
||||||
.copied()
|
} else {
|
||||||
.chain(
|
add_endpoint_routes(entry, &agent_device, &status);
|
||||||
agent_device
|
}
|
||||||
.endpoints
|
}
|
||||||
.iter()
|
|
||||||
.filter_map(|endpoint| endpoint.key.ip),
|
fn add_names_and_identity(
|
||||||
)
|
entry: &mut FleetAccumulator,
|
||||||
.min();
|
agent_device: &AgentDeviceWithChildren,
|
||||||
entry.label_ip = entry.label_ip.into_iter().chain(label_ip).min();
|
context: &FleetBuildContext,
|
||||||
let device_presence = agent_device.presence();
|
) {
|
||||||
let device_offline = device_presence == Presence::Offline;
|
if let Some(summary) = device_known_device(agent_device, context)
|
||||||
if let Some(summary) = device_known_device(&agent_device, context)
|
|
||||||
&& entry.known_device.is_none()
|
&& entry.known_device.is_none()
|
||||||
{
|
{
|
||||||
if let Some(ref name) = agent_device.device.display_name {
|
if let Some(ref name) = agent_device.device.display_name {
|
||||||
@@ -222,33 +228,19 @@ fn add_agent_device_to_entry(
|
|||||||
entry.pinned = summary.pinned;
|
entry.pinned = summary.pinned;
|
||||||
entry.known_device = Some(summary);
|
entry.known_device = Some(summary);
|
||||||
}
|
}
|
||||||
if agent_device.endpoints.is_empty() {
|
|
||||||
for mac in &agent_device.macs {
|
|
||||||
entry.macs.insert(*mac);
|
|
||||||
}
|
|
||||||
if !device_offline {
|
|
||||||
for ip in &agent_device.ips {
|
|
||||||
entry.ips.insert(*ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for endpoint in &agent_device.endpoints {
|
|
||||||
if let Some(mac) = endpoint.key.mac {
|
|
||||||
entry.macs.insert(mac);
|
|
||||||
}
|
|
||||||
if endpoint_ip_is_summary_eligible(endpoint.key.source, endpoint.presence)
|
|
||||||
&& let Some(ip) = endpoint.key.ip
|
|
||||||
{
|
|
||||||
entry.ips.insert(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for hostname in &agent_device.hostnames {
|
for hostname in &agent_device.hostnames {
|
||||||
if entry.display_name.is_none() {
|
if entry.display_name.is_none() {
|
||||||
entry.display_name = Some(hostname.clone());
|
entry.display_name = Some(hostname.clone());
|
||||||
}
|
}
|
||||||
entry.hostnames.insert(hostname.clone());
|
entry.hostnames.insert(hostname.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_agent_summary(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
context: &FleetBuildContext,
|
||||||
|
) -> AgentRuntimeStatus {
|
||||||
let first_seen = agent_device.device.first_seen();
|
let first_seen = agent_device.device.first_seen();
|
||||||
let last_seen = agent_device.device.last_seen();
|
let last_seen = agent_device.device.last_seen();
|
||||||
entry.first_seen_unix = Some(
|
entry.first_seen_unix = Some(
|
||||||
@@ -263,7 +255,7 @@ fn add_agent_device_to_entry(
|
|||||||
.map(|current| current.max(last_seen))
|
.map(|current| current.max(last_seen))
|
||||||
.unwrap_or(last_seen),
|
.unwrap_or(last_seen),
|
||||||
);
|
);
|
||||||
entry.presence = std::cmp::max(entry.presence, device_presence);
|
entry.presence = std::cmp::max(entry.presence, agent_device.presence());
|
||||||
|
|
||||||
let agent_id = agent_device.device.agent_id.clone();
|
let agent_id = agent_device.device.agent_id.clone();
|
||||||
let status = context
|
let status = context
|
||||||
@@ -288,105 +280,7 @@ fn add_agent_device_to_entry(
|
|||||||
connected: status.connected,
|
connected: status.connected,
|
||||||
last_seen_unix: last_seen,
|
last_seen_unix: last_seen,
|
||||||
});
|
});
|
||||||
|
status
|
||||||
if agent_device.endpoints.is_empty() {
|
|
||||||
entry.sources.insert("device".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
for endpoint in &agent_device.endpoints {
|
|
||||||
let source = endpoint_source_label(endpoint.key.source).to_string();
|
|
||||||
entry.sources.insert(source.clone());
|
|
||||||
let endpoint_last_seen = endpoint.last_seen_unix.unwrap_or(last_seen);
|
|
||||||
let endpoint_first_seen = endpoint.first_seen_unix.or(Some(first_seen));
|
|
||||||
entry.endpoints.push(FleetDeviceEndpoint {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
nickname: status.nickname.clone(),
|
|
||||||
connected: status.connected,
|
|
||||||
source: source.clone(),
|
|
||||||
mac: endpoint.key.mac,
|
|
||||||
ip: endpoint.key.ip,
|
|
||||||
hostname: endpoint.hostname.clone(),
|
|
||||||
interface: endpoint.interface.clone(),
|
|
||||||
presence: endpoint.presence,
|
|
||||||
first_seen_unix: endpoint_first_seen,
|
|
||||||
last_seen_unix: Some(endpoint_last_seen),
|
|
||||||
});
|
|
||||||
|
|
||||||
let rid = route_id(
|
|
||||||
&agent_id,
|
|
||||||
endpoint.key.mac.as_ref(),
|
|
||||||
endpoint.key.ip.as_ref(),
|
|
||||||
);
|
|
||||||
let wakeable = status.connected && endpoint.key.mac.is_some();
|
|
||||||
insert_route(
|
|
||||||
entry,
|
|
||||||
FleetWakeRoute {
|
|
||||||
route_id: rid,
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
nickname: status.nickname.clone(),
|
|
||||||
connected: status.connected,
|
|
||||||
mac: endpoint.key.mac,
|
|
||||||
ip: endpoint.key.ip,
|
|
||||||
hostname: endpoint.hostname.clone(),
|
|
||||||
interface: endpoint.interface.clone(),
|
|
||||||
source,
|
|
||||||
presence: endpoint.presence,
|
|
||||||
last_seen_unix: endpoint_last_seen,
|
|
||||||
wakeable,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent_device.endpoints.is_empty() && !agent_device.macs.is_empty() {
|
|
||||||
for mac in &agent_device.macs {
|
|
||||||
let ip_for_mac = agent_device.ips.first().copied();
|
|
||||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
|
||||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref());
|
|
||||||
let wakeable = status.connected;
|
|
||||||
insert_route(
|
|
||||||
entry,
|
|
||||||
FleetWakeRoute {
|
|
||||||
route_id: rid,
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
nickname: status.nickname.clone(),
|
|
||||||
connected: status.connected,
|
|
||||||
mac: Some(*mac),
|
|
||||||
ip: ip_for_mac,
|
|
||||||
hostname: hostname_for_mac,
|
|
||||||
interface: None,
|
|
||||||
source: "device".to_string(),
|
|
||||||
presence: device_presence,
|
|
||||||
last_seen_unix: last_seen,
|
|
||||||
wakeable,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent_device.endpoints.is_empty()
|
|
||||||
&& agent_device.macs.is_empty()
|
|
||||||
&& let Some(ip) = agent_device.ips.first()
|
|
||||||
{
|
|
||||||
let hostname = agent_device.hostnames.first().cloned();
|
|
||||||
let rid = route_id(&agent_id, None, Some(ip));
|
|
||||||
insert_route(
|
|
||||||
entry,
|
|
||||||
FleetWakeRoute {
|
|
||||||
route_id: rid,
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
nickname: status.nickname.clone(),
|
|
||||||
connected: status.connected,
|
|
||||||
mac: None,
|
|
||||||
ip: Some(*ip),
|
|
||||||
hostname,
|
|
||||||
interface: None,
|
|
||||||
source: "device".to_string(),
|
|
||||||
presence: device_presence,
|
|
||||||
last_seen_unix: last_seen,
|
|
||||||
wakeable: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
||||||
@@ -439,37 +333,6 @@ fn device_known_device(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint_ip_is_summary_eligible(source: EndpointSource, presence: Presence) -> bool {
|
|
||||||
match source {
|
|
||||||
EndpointSource::DhcpLease => true,
|
|
||||||
EndpointSource::Neighbor => presence != Presence::Offline,
|
|
||||||
EndpointSource::HookNeighbor | EndpointSource::HookDhcp => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_route(entry: &mut FleetAccumulator, candidate: FleetWakeRoute) {
|
|
||||||
match entry.routes.entry(candidate.route_id.clone()) {
|
|
||||||
std::collections::btree_map::Entry::Vacant(slot) => {
|
|
||||||
slot.insert(candidate);
|
|
||||||
}
|
|
||||||
std::collections::btree_map::Entry::Occupied(mut slot) => {
|
|
||||||
if compare_route_quality(&candidate, slot.get()).is_gt() {
|
|
||||||
slot.insert(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_route_quality(a: &FleetWakeRoute, b: &FleetWakeRoute) -> std::cmp::Ordering {
|
|
||||||
a.connected
|
|
||||||
.cmp(&b.connected)
|
|
||||||
.then_with(|| a.wakeable.cmp(&b.wakeable))
|
|
||||||
.then_with(|| a.ip.is_some().cmp(&b.ip.is_some()))
|
|
||||||
.then_with(|| a.presence.cmp(&b.presence))
|
|
||||||
.then_with(|| a.last_seen_unix.cmp(&b.last_seen_unix))
|
|
||||||
.then_with(|| source_quality_rank(&a.source).cmp(&source_quality_rank(&b.source)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||||
KnownDeviceSummary {
|
KnownDeviceSummary {
|
||||||
device_id: device.device_id.clone(),
|
device_id: device.device_id.clone(),
|
||||||
@@ -480,6 +343,7 @@ pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
|||||||
|
|
||||||
impl FleetAccumulator {
|
impl FleetAccumulator {
|
||||||
fn into_response(self) -> FleetDevice {
|
fn into_response(self) -> FleetDevice {
|
||||||
|
let display_name = self.display_label();
|
||||||
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
||||||
route_candidates
|
route_candidates
|
||||||
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
||||||
@@ -487,43 +351,6 @@ impl FleetAccumulator {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|route| route.wakeable)
|
.find(|route| route.wakeable)
|
||||||
.cloned();
|
.cloned();
|
||||||
let display_name = self
|
|
||||||
.display_name
|
|
||||||
.or_else(|| self.hostnames.iter().next().cloned())
|
|
||||||
.or_else(|| {
|
|
||||||
// Summary IPs omit offline endpoints; labels must not discard
|
|
||||||
// their address evidence or imply that unknown means offline.
|
|
||||||
let address = self
|
|
||||||
.ips
|
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.copied()
|
|
||||||
.or(self.label_ip)
|
|
||||||
.or_else(|| {
|
|
||||||
self.endpoints
|
|
||||||
.iter()
|
|
||||||
.filter_map(|endpoint| endpoint.ip)
|
|
||||||
.min()
|
|
||||||
})
|
|
||||||
.map(|ip| ip.to_string())
|
|
||||||
.or_else(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
|
||||||
.or_else(|| {
|
|
||||||
self.endpoints
|
|
||||||
.iter()
|
|
||||||
.find_map(|endpoint| endpoint.mac)
|
|
||||||
.map(|mac| mac.to_string())
|
|
||||||
});
|
|
||||||
address.map(|address| {
|
|
||||||
let prefix = if self.presence == Presence::Offline {
|
|
||||||
"Offline device"
|
|
||||||
} else {
|
|
||||||
"Device"
|
|
||||||
};
|
|
||||||
format!("{prefix}: {address}")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "(unknown device)".to_string());
|
|
||||||
|
|
||||||
FleetDevice {
|
FleetDevice {
|
||||||
device_key: self.device_key,
|
device_key: self.device_key,
|
||||||
display_name,
|
display_name,
|
||||||
@@ -544,34 +371,6 @@ impl FleetAccumulator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint_source_label(source: EndpointSource) -> &'static str {
|
|
||||||
match source {
|
|
||||||
EndpointSource::Neighbor => "neighbor",
|
|
||||||
EndpointSource::DhcpLease => "dhcp_lease",
|
|
||||||
EndpointSource::HookNeighbor => "hook_neighbor",
|
|
||||||
EndpointSource::HookDhcp => "hook_dhcp",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_quality_rank(source: &str) -> u8 {
|
|
||||||
match source {
|
|
||||||
"neighbor" => 4,
|
|
||||||
"dhcp_lease" => 3,
|
|
||||||
"hook_neighbor" => 2,
|
|
||||||
"hook_dhcp" => 1,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>) -> String {
|
|
||||||
format!(
|
|
||||||
"{}|{}|{}",
|
|
||||||
agent_id,
|
|
||||||
mac.map(|m| m.to_string()).unwrap_or_default(),
|
|
||||||
ip.map(|i| i.to_string()).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_filter(value: Option<&str>) -> Option<String> {
|
fn normalize_filter(value: Option<&str>) -> Option<String> {
|
||||||
value
|
value
|
||||||
.map(|value| value.trim().to_ascii_lowercase())
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Current-address summaries and naming-only evidence have different eligibility.
|
||||||
|
|
||||||
|
use super::FleetAccumulator;
|
||||||
|
use crate::state::AgentDeviceWithChildren;
|
||||||
|
use wakey_core::{EndpointSource, Presence};
|
||||||
|
|
||||||
|
/// Keep naming evidence distinct from addresses eligible for the summary.
|
||||||
|
pub(super) fn add_address_evidence(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
) {
|
||||||
|
// Retain address evidence for naming only, without promoting stale IPs into
|
||||||
|
// the active summary or wake-route candidates.
|
||||||
|
let fallback_ip = agent_device
|
||||||
|
.ips
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(
|
||||||
|
agent_device
|
||||||
|
.endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| endpoint.key.ip),
|
||||||
|
)
|
||||||
|
.min();
|
||||||
|
entry.fallback_ip = entry.fallback_ip.into_iter().chain(fallback_ip).min();
|
||||||
|
if agent_device.endpoints.is_empty() {
|
||||||
|
for mac in &agent_device.macs {
|
||||||
|
entry.macs.insert(*mac);
|
||||||
|
}
|
||||||
|
if agent_device.presence() != Presence::Offline {
|
||||||
|
for ip in &agent_device.ips {
|
||||||
|
entry.ips.insert(*ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for endpoint in &agent_device.endpoints {
|
||||||
|
if let Some(mac) = endpoint.key.mac {
|
||||||
|
entry.macs.insert(mac);
|
||||||
|
}
|
||||||
|
if endpoint_ip_is_summary_eligible(endpoint.key.source, endpoint.presence)
|
||||||
|
&& let Some(ip) = endpoint.key.ip
|
||||||
|
{
|
||||||
|
entry.ips.insert(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_ip_is_summary_eligible(source: EndpointSource, presence: Presence) -> bool {
|
||||||
|
match source {
|
||||||
|
EndpointSource::DhcpLease => true,
|
||||||
|
EndpointSource::Neighbor => presence != Presence::Offline,
|
||||||
|
EndpointSource::HookNeighbor | EndpointSource::HookDhcp => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FleetAccumulator {
|
||||||
|
/// Names and MACs outrank address evidence; presence belongs in the badge.
|
||||||
|
/// Summary IPs outrank naming-only fallback IPs, which may be inactive.
|
||||||
|
pub(super) fn display_label(&self) -> String {
|
||||||
|
self.display_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.hostnames.iter().next().cloned())
|
||||||
|
.or_else(|| {
|
||||||
|
self.macs
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|mac| mac.to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
self.ips
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.copied()
|
||||||
|
.or(self.fallback_ip)
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
})
|
||||||
|
.map(|address| format!("Device: {address}"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "(unknown device)".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! Build and rank agent-specific wake routes without promoting IPs into summaries.
|
||||||
|
|
||||||
|
use super::{AgentRuntimeStatus, FleetAccumulator, FleetDeviceEndpoint, FleetWakeRoute};
|
||||||
|
use crate::state::AgentDeviceWithChildren;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use wakey_core::EndpointSource;
|
||||||
|
|
||||||
|
/// Endpoint evidence is preserved even when its IP is excluded from the summary.
|
||||||
|
pub(super) fn add_endpoint_routes(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
status: &AgentRuntimeStatus,
|
||||||
|
) {
|
||||||
|
let agent_id = &agent_device.device.agent_id;
|
||||||
|
let first_seen = agent_device.device.first_seen();
|
||||||
|
let last_seen = agent_device.device.last_seen();
|
||||||
|
for endpoint in &agent_device.endpoints {
|
||||||
|
let source = endpoint_source_label(endpoint.key.source).to_string();
|
||||||
|
entry.sources.insert(source.clone());
|
||||||
|
let endpoint_last_seen = endpoint.last_seen_unix.unwrap_or(last_seen);
|
||||||
|
let endpoint_first_seen = endpoint.first_seen_unix.or(Some(first_seen));
|
||||||
|
entry.endpoints.push(FleetDeviceEndpoint {
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
nickname: status.nickname.clone(),
|
||||||
|
connected: status.connected,
|
||||||
|
source: source.clone(),
|
||||||
|
mac: endpoint.key.mac,
|
||||||
|
ip: endpoint.key.ip,
|
||||||
|
hostname: endpoint.hostname.clone(),
|
||||||
|
interface: endpoint.interface.clone(),
|
||||||
|
presence: endpoint.presence,
|
||||||
|
first_seen_unix: endpoint_first_seen,
|
||||||
|
last_seen_unix: Some(endpoint_last_seen),
|
||||||
|
});
|
||||||
|
|
||||||
|
let rid = route_id(
|
||||||
|
agent_id,
|
||||||
|
endpoint.key.mac.as_ref(),
|
||||||
|
endpoint.key.ip.as_ref(),
|
||||||
|
);
|
||||||
|
let wakeable = status.connected && endpoint.key.mac.is_some();
|
||||||
|
insert_route(
|
||||||
|
entry,
|
||||||
|
FleetWakeRoute {
|
||||||
|
route_id: rid,
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
nickname: status.nickname.clone(),
|
||||||
|
connected: status.connected,
|
||||||
|
mac: endpoint.key.mac,
|
||||||
|
ip: endpoint.key.ip,
|
||||||
|
hostname: endpoint.hostname.clone(),
|
||||||
|
interface: endpoint.interface.clone(),
|
||||||
|
source,
|
||||||
|
presence: endpoint.presence,
|
||||||
|
last_seen_unix: endpoint_last_seen,
|
||||||
|
wakeable,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility for stored rows without authoritative endpoint evidence.
|
||||||
|
pub(super) fn add_legacy_routes(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
status: &AgentRuntimeStatus,
|
||||||
|
) {
|
||||||
|
entry.sources.insert("device".to_string());
|
||||||
|
let agent_id = &agent_device.device.agent_id;
|
||||||
|
let device_presence = agent_device.presence();
|
||||||
|
let last_seen = agent_device.device.last_seen();
|
||||||
|
if !agent_device.macs.is_empty() {
|
||||||
|
for mac in &agent_device.macs {
|
||||||
|
let ip_for_mac = agent_device.ips.first().copied();
|
||||||
|
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||||
|
let rid = route_id(agent_id, Some(mac), ip_for_mac.as_ref());
|
||||||
|
let wakeable = status.connected;
|
||||||
|
insert_route(
|
||||||
|
entry,
|
||||||
|
FleetWakeRoute {
|
||||||
|
route_id: rid,
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
nickname: status.nickname.clone(),
|
||||||
|
connected: status.connected,
|
||||||
|
mac: Some(*mac),
|
||||||
|
ip: ip_for_mac,
|
||||||
|
hostname: hostname_for_mac,
|
||||||
|
interface: None,
|
||||||
|
source: "device".to_string(),
|
||||||
|
presence: device_presence,
|
||||||
|
last_seen_unix: last_seen,
|
||||||
|
wakeable,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent_device.macs.is_empty()
|
||||||
|
&& let Some(ip) = agent_device.ips.first()
|
||||||
|
{
|
||||||
|
let hostname = agent_device.hostnames.first().cloned();
|
||||||
|
let rid = route_id(agent_id, None, Some(ip));
|
||||||
|
insert_route(
|
||||||
|
entry,
|
||||||
|
FleetWakeRoute {
|
||||||
|
route_id: rid,
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
nickname: status.nickname.clone(),
|
||||||
|
connected: status.connected,
|
||||||
|
mac: None,
|
||||||
|
ip: Some(*ip),
|
||||||
|
hostname,
|
||||||
|
interface: None,
|
||||||
|
source: "device".to_string(),
|
||||||
|
presence: device_presence,
|
||||||
|
last_seen_unix: last_seen,
|
||||||
|
wakeable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_route(entry: &mut FleetAccumulator, candidate: FleetWakeRoute) {
|
||||||
|
match entry.routes.entry(candidate.route_id.clone()) {
|
||||||
|
std::collections::btree_map::Entry::Vacant(slot) => {
|
||||||
|
slot.insert(candidate);
|
||||||
|
}
|
||||||
|
std::collections::btree_map::Entry::Occupied(mut slot) => {
|
||||||
|
if compare_route_quality(&candidate, slot.get()).is_gt() {
|
||||||
|
slot.insert(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn compare_route_quality(a: &FleetWakeRoute, b: &FleetWakeRoute) -> std::cmp::Ordering {
|
||||||
|
a.connected
|
||||||
|
.cmp(&b.connected)
|
||||||
|
.then_with(|| a.wakeable.cmp(&b.wakeable))
|
||||||
|
.then_with(|| a.ip.is_some().cmp(&b.ip.is_some()))
|
||||||
|
.then_with(|| a.presence.cmp(&b.presence))
|
||||||
|
.then_with(|| a.last_seen_unix.cmp(&b.last_seen_unix))
|
||||||
|
.then_with(|| source_quality_rank(&a.source).cmp(&source_quality_rank(&b.source)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_source_label(source: EndpointSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
EndpointSource::Neighbor => "neighbor",
|
||||||
|
EndpointSource::DhcpLease => "dhcp_lease",
|
||||||
|
EndpointSource::HookNeighbor => "hook_neighbor",
|
||||||
|
EndpointSource::HookDhcp => "hook_dhcp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_quality_rank(source: &str) -> u8 {
|
||||||
|
match source {
|
||||||
|
"neighbor" => 4,
|
||||||
|
"dhcp_lease" => 3,
|
||||||
|
"hook_neighbor" => 2,
|
||||||
|
"hook_dhcp" => 1,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>) -> String {
|
||||||
|
format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
agent_id,
|
||||||
|
mac.map(|m| m.to_string()).unwrap_or_default(),
|
||||||
|
ip.map(|i| i.to_string()).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -728,7 +728,7 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(devices.len(), 1);
|
assert_eq!(devices.len(), 1);
|
||||||
assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
|
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
|
||||||
assert!(devices.is_empty());
|
assert!(devices.is_empty());
|
||||||
}
|
}
|
||||||
@@ -762,7 +762,7 @@ fn endpoint_presence_overrides_conflicting_cached_summary() {
|
|||||||
assert_eq!(devices[0].endpoints[0].presence, evidence);
|
assert_eq!(devices[0].endpoints[0].presence, evidence);
|
||||||
assert_eq!(devices[0].agents[0].connected, connected);
|
assert_eq!(devices[0].agents[0].connected, connected);
|
||||||
if evidence == Presence::Offline {
|
if evidence == Presence::Offline {
|
||||||
assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
assert!(devices[0].ips.is_empty());
|
assert!(devices[0].ips.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -776,7 +776,7 @@ fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
|
|||||||
Presence::Offline,
|
Presence::Offline,
|
||||||
None,
|
None,
|
||||||
Some("192.168.1.2"),
|
Some("192.168.1.2"),
|
||||||
"Offline device: 192.168.1.2",
|
"Device: 192.168.1.2",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
Presence::Unknown,
|
Presence::Unknown,
|
||||||
@@ -788,7 +788,7 @@ fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
|
|||||||
Presence::Offline,
|
Presence::Offline,
|
||||||
Some("aa:bb:cc:dd:ee:01"),
|
Some("aa:bb:cc:dd:ee:01"),
|
||||||
None,
|
None,
|
||||||
"Offline device: AA:BB:CC:DD:EE:01",
|
"Device: AA:BB:CC:DD:EE:01",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
||||||
@@ -806,6 +806,75 @@ fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_mac_device_does_not_use_inactive_ip_or_presence_as_its_name() {
|
||||||
|
for presence in [Presence::Offline, Presence::Unknown, Presence::Online] {
|
||||||
|
let mut row =
|
||||||
|
offline_ip_only_unknown("agent-a", "mac:22:07:53:BF:FC:12", "192.168.100.125", 20);
|
||||||
|
row.macs = vec!["22:07:53:bf:fc:12".parse().unwrap()];
|
||||||
|
row.hostnames.clear();
|
||||||
|
row.endpoints = [EndpointSource::Neighbor, EndpointSource::HookNeighbor]
|
||||||
|
.into_iter()
|
||||||
|
.map(|source| {
|
||||||
|
let mut endpoint = endpoint_with_presence(
|
||||||
|
source,
|
||||||
|
Some("22:07:53:bf:fc:12"),
|
||||||
|
Some("192.168.100.125"),
|
||||||
|
presence,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
endpoint.hostname = None;
|
||||||
|
endpoint
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 22:07:53:BF:FC:12");
|
||||||
|
assert_eq!(devices[0].presence, presence);
|
||||||
|
assert_eq!(devices[0].endpoints.len(), 2);
|
||||||
|
if presence == Presence::Offline {
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ip_only_label_prefers_summary_ip_and_uses_inactive_evidence_only_as_fallback() {
|
||||||
|
for has_current_ip in [true, false] {
|
||||||
|
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
||||||
|
let mut inactive = endpoint_with_presence(
|
||||||
|
EndpointSource::Neighbor,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.2"),
|
||||||
|
Presence::Offline,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
inactive.hostname = None;
|
||||||
|
row.endpoints = vec![inactive];
|
||||||
|
if has_current_ip {
|
||||||
|
let mut current = endpoint_with_presence(
|
||||||
|
EndpointSource::Neighbor,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.10"),
|
||||||
|
Presence::Online,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
current.hostname = None;
|
||||||
|
row.endpoints.push(current);
|
||||||
|
}
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||||
|
if has_current_ip {
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.10");
|
||||||
|
assert_eq!(
|
||||||
|
devices[0].ips,
|
||||||
|
vec!["192.168.1.10".parse::<IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
||||||
let mut devices = build_fleet_devices(
|
let mut devices = build_fleet_devices(
|
||||||
@@ -827,7 +896,7 @@ fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(devices.len(), 1);
|
assert_eq!(devices.len(), 1);
|
||||||
assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use super::Store;
|
|||||||
use super::helpers::core::*;
|
use super::helpers::core::*;
|
||||||
use crate::state::types::*;
|
use crate::state::types::*;
|
||||||
|
|
||||||
|
mod snapshot;
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
/// Expire agent-reported devices, preserving all operator-saved identities.
|
/// Expire agent-reported devices, preserving all operator-saved identities.
|
||||||
/// `last_seen_unix` on the parent is CC snapshot inclusion time, not proof
|
/// `last_seen_unix` on the parent is CC snapshot inclusion time, not proof
|
||||||
@@ -52,7 +54,6 @@ impl Store {
|
|||||||
|
|
||||||
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
||||||
let snapshot_time = now_unix();
|
let snapshot_time = now_unix();
|
||||||
let snapshot_time_i64 = i64::try_from(snapshot_time).context("snapshot time overflow")?;
|
|
||||||
|
|
||||||
let existing_keys: Vec<String> = sqlx::query_scalar!(
|
let existing_keys: Vec<String> = sqlx::query_scalar!(
|
||||||
"SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
"SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
||||||
@@ -69,173 +70,7 @@ impl Store {
|
|||||||
let device_key = device_key_from_id(device_id);
|
let device_key = device_key_from_id(device_id);
|
||||||
incoming_keys.insert(device_key.clone());
|
incoming_keys.insert(device_key.clone());
|
||||||
|
|
||||||
let endpoints = endpoints_for_storage(device);
|
snapshot::write_device(&mut tx, agent_id, &device_key, device, snapshot_time).await?;
|
||||||
let presence = presence_to_str(endpoint_presence(&endpoints, device.presence));
|
|
||||||
let display_name: Option<String> = None;
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
||||||
ON CONFLICT (agent_id, device_key) DO UPDATE SET
|
|
||||||
presence = excluded.presence,
|
|
||||||
display_name = excluded.display_name,
|
|
||||||
last_seen_unix = excluded.last_seen_unix",
|
|
||||||
agent_id,
|
|
||||||
device_key,
|
|
||||||
presence,
|
|
||||||
display_name,
|
|
||||||
snapshot_time_i64,
|
|
||||||
snapshot_time_i64
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed upserting agent device")?;
|
|
||||||
|
|
||||||
// Replace child rows.
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device macs")?;
|
|
||||||
|
|
||||||
if !device.macs.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_macs (agent_id, device_key, mac) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.macs, |mut b, mac| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(mac.to_string().to_ascii_lowercase());
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device macs")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device ips")?;
|
|
||||||
|
|
||||||
if !device.ips.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_ips (agent_id, device_key, ip) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.ips, |mut b, ip| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(ip.to_string());
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device ips")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM agent_device_endpoints WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
)
|
|
||||||
.bind(agent_id)
|
|
||||||
.bind(&device_key)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device endpoints")?;
|
|
||||||
|
|
||||||
if !endpoints.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_endpoints \
|
|
||||||
(agent_id, device_key, endpoint_key, source, mac, ip, hostname, interface, presence, first_seen_unix, last_seen_unix) ",
|
|
||||||
);
|
|
||||||
builder.push_values(endpoints, |mut b, endpoint| {
|
|
||||||
let first_seen = endpoint.first_seen_unix.unwrap_or(snapshot_time);
|
|
||||||
let last_seen = endpoint.last_seen_unix.unwrap_or(snapshot_time);
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(endpoint_storage_key(&endpoint))
|
|
||||||
.push_bind(endpoint_source_to_str(endpoint.key.source))
|
|
||||||
.push_bind(
|
|
||||||
endpoint
|
|
||||||
.key
|
|
||||||
.mac
|
|
||||||
.map(|mac| mac.to_string().to_ascii_lowercase()),
|
|
||||||
)
|
|
||||||
.push_bind(endpoint.key.ip.map(|ip| ip.to_string()))
|
|
||||||
.push_bind(endpoint.hostname.clone())
|
|
||||||
.push_bind(endpoint.interface.clone())
|
|
||||||
.push_bind(presence_to_str(endpoint.presence))
|
|
||||||
.push_bind(i64::try_from(first_seen).unwrap_or(i64::MAX))
|
|
||||||
.push_bind(i64::try_from(last_seen).unwrap_or(i64::MAX));
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device endpoints")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device hostnames")?;
|
|
||||||
|
|
||||||
if !device.names.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_hostnames (agent_id, device_key, hostname) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.names, |mut b, hostname| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(hostname);
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device hostnames")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device facts")?;
|
|
||||||
|
|
||||||
if !device.observations.is_empty() {
|
|
||||||
let mut facts_json = Vec::with_capacity(device.observations.len());
|
|
||||||
for obs in &device.observations {
|
|
||||||
facts_json.push(serde_json::to_string(obs).context("failed serializing fact")?);
|
|
||||||
}
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_facts (agent_id, device_key, fact_json) ",
|
|
||||||
);
|
|
||||||
builder.push_values(facts_json, |mut b, fact| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(fact);
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device facts")?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for old_key in existing_keys {
|
for old_key in existing_keys {
|
||||||
@@ -784,6 +619,56 @@ mod tests {
|
|||||||
assert_eq!(rows[0].endpoints[0].presence, Presence::Unknown);
|
assert_eq!(rows[0].endpoints[0].presence, Presence::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_write_failure_rolls_back_the_whole_snapshot_without_invalidating_fleet() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
let original = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "original");
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[original])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = store
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
let revision = store.fleet_revision();
|
||||||
|
|
||||||
|
let mut updated = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.20", "updated");
|
||||||
|
updated.neighbors[0].state = NeighborState::Failed;
|
||||||
|
let mut invalid = sample_device("aa:bb:cc:dd:ee:02", "192.168.1.30", "duplicate");
|
||||||
|
// The second device fails late, after the first device and several child
|
||||||
|
// tables were written. Every helper must share the outer transaction.
|
||||||
|
invalid.names.push("duplicate".into());
|
||||||
|
let error = store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[updated, invalid])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("failed inserting device hostnames")
|
||||||
|
);
|
||||||
|
assert_eq!(store.fleet_revision(), revision);
|
||||||
|
|
||||||
|
let mut rows = store
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1, "the failed new device must not survive");
|
||||||
|
let after = rows.remove(0);
|
||||||
|
assert_eq!(after.device.device_key, before.device.device_key);
|
||||||
|
assert_eq!(after.device.presence, before.device.presence);
|
||||||
|
assert_eq!(after.device.first_seen_unix, before.device.first_seen_unix);
|
||||||
|
assert_eq!(after.device.last_seen_unix, before.device.last_seen_unix);
|
||||||
|
assert_eq!(after.macs, before.macs);
|
||||||
|
assert_eq!(after.ips, before.ips);
|
||||||
|
assert_eq!(after.hostnames, before.hostnames);
|
||||||
|
assert_eq!(after.endpoints, before.endpoints);
|
||||||
|
assert_eq!(after.facts, before.facts);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn snapshot_inserts_devices_and_children() {
|
async fn snapshot_inserts_devices_and_children() {
|
||||||
let ts = TestStore::new().await;
|
let ts = TestStore::new().await;
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Writes one snapshot device using the caller's transaction. No helper commits.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
use wakey_core::{Device, DeviceEndpoint};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
endpoint_presence, endpoint_source_to_str, endpoint_storage_key, endpoints_for_storage,
|
||||||
|
presence_to_str,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) async fn write_device(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let endpoints = endpoints_for_storage(device);
|
||||||
|
let presence = presence_to_str(endpoint_presence(&endpoints, device.presence));
|
||||||
|
upsert_parent(connection, agent_id, device_key, presence, snapshot_time).await?;
|
||||||
|
replace_macs(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_ips(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_endpoints(connection, agent_id, device_key, &endpoints, snapshot_time).await?;
|
||||||
|
replace_hostnames(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_facts(connection, agent_id, device_key, device).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates snapshot inclusion time, while preserving the parent's first-seen time.
|
||||||
|
async fn upsert_parent(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
presence: &str,
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let snapshot_time_i64 = i64::try_from(snapshot_time).context("snapshot time overflow")?;
|
||||||
|
let display_name: Option<String> = None;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT (agent_id, device_key) DO UPDATE SET
|
||||||
|
presence = excluded.presence,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
last_seen_unix = excluded.last_seen_unix",
|
||||||
|
agent_id,
|
||||||
|
device_key,
|
||||||
|
presence,
|
||||||
|
display_name,
|
||||||
|
snapshot_time_i64,
|
||||||
|
snapshot_time_i64
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed upserting agent device")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_macs(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device macs")?;
|
||||||
|
|
||||||
|
if !device.macs.is_empty() {
|
||||||
|
let mut builder =
|
||||||
|
sqlx::QueryBuilder::new("INSERT INTO agent_device_macs (agent_id, device_key, mac) ");
|
||||||
|
builder.push_values(&device.macs, |mut b, mac| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(mac.to_string().to_ascii_lowercase());
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device macs")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_ips(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device ips")?;
|
||||||
|
|
||||||
|
if !device.ips.is_empty() {
|
||||||
|
let mut builder =
|
||||||
|
sqlx::QueryBuilder::new("INSERT INTO agent_device_ips (agent_id, device_key, ip) ");
|
||||||
|
builder.push_values(&device.ips, |mut b, ip| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(ip.to_string());
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device ips")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_endpoints(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
endpoints: &[DeviceEndpoint],
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM agent_device_endpoints WHERE agent_id = ?1 AND device_key = ?2")
|
||||||
|
.bind(agent_id)
|
||||||
|
.bind(device_key)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device endpoints")?;
|
||||||
|
|
||||||
|
if !endpoints.is_empty() {
|
||||||
|
let mut builder = sqlx::QueryBuilder::new(
|
||||||
|
"INSERT INTO agent_device_endpoints \
|
||||||
|
(agent_id, device_key, endpoint_key, source, mac, ip, hostname, interface, presence, first_seen_unix, last_seen_unix) ",
|
||||||
|
);
|
||||||
|
builder.push_values(endpoints, |mut b, endpoint| {
|
||||||
|
let first_seen = endpoint.first_seen_unix.unwrap_or(snapshot_time);
|
||||||
|
let last_seen = endpoint.last_seen_unix.unwrap_or(snapshot_time);
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(endpoint_storage_key(endpoint))
|
||||||
|
.push_bind(endpoint_source_to_str(endpoint.key.source))
|
||||||
|
.push_bind(
|
||||||
|
endpoint
|
||||||
|
.key
|
||||||
|
.mac
|
||||||
|
.map(|mac| mac.to_string().to_ascii_lowercase()),
|
||||||
|
)
|
||||||
|
.push_bind(endpoint.key.ip.map(|ip| ip.to_string()))
|
||||||
|
.push_bind(endpoint.hostname.clone())
|
||||||
|
.push_bind(endpoint.interface.clone())
|
||||||
|
.push_bind(presence_to_str(endpoint.presence))
|
||||||
|
.push_bind(i64::try_from(first_seen).unwrap_or(i64::MAX))
|
||||||
|
.push_bind(i64::try_from(last_seen).unwrap_or(i64::MAX));
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device endpoints")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_hostnames(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device hostnames")?;
|
||||||
|
|
||||||
|
if !device.names.is_empty() {
|
||||||
|
let mut builder = sqlx::QueryBuilder::new(
|
||||||
|
"INSERT INTO agent_device_hostnames (agent_id, device_key, hostname) ",
|
||||||
|
);
|
||||||
|
builder.push_values(&device.names, |mut b, hostname| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(hostname);
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device hostnames")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_facts(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device facts")?;
|
||||||
|
|
||||||
|
if !device.observations.is_empty() {
|
||||||
|
let mut facts_json = Vec::with_capacity(device.observations.len());
|
||||||
|
for obs in &device.observations {
|
||||||
|
facts_json.push(serde_json::to_string(obs).context("failed serializing fact")?);
|
||||||
|
}
|
||||||
|
let mut builder = sqlx::QueryBuilder::new(
|
||||||
|
"INSERT INTO agent_device_facts (agent_id, device_key, fact_json) ",
|
||||||
|
);
|
||||||
|
builder.push_values(facts_json, |mut b, fact| {
|
||||||
|
b.push_bind(agent_id).push_bind(device_key).push_bind(fact);
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device facts")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user