Skip to main content

uptrakit_wire/
service_profile.rs

1//! Behavioral profiles derived from a service's persisted capability set.
2//!
3//! [`ServiceProfile`] is a derived enum — never stored in the database. It is
4//! computed from `BTreeSet<Capability>` via [`ServiceProfile::from_capabilities`]
5//! and drives controller-side behavioral defaults (ping interval, shutdown
6//! timeout, human-readable label).
7
8use std::collections::BTreeSet;
9
10use crate::Capability;
11
12/// Behavioral profile derived from a service's capability set.
13///
14/// | Profile | Key capability | Services |
15/// | --- | --- | --- |
16/// | `UpdateTracker` | `Capability::UpdateTracking` | MQTT service |
17/// | `Agent` | `Capability::SoftwareDiscovery` | Local agent, SSH agent |
18/// | `Scheduler` | `Capability::Scheduler` | External task scheduler |
19///
20/// `Unknown` is the fallback for unrecognized capability combinations.
21/// `ServiceProfile` is never persisted — it is always derived from capabilities.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum ServiceProfile {
25    /// Update-tracking service (has `UpdateTracking` capability).
26    UpdateTracker,
27    /// Agent service — local or SSH-backed (has `SoftwareDiscovery` capability).
28    Agent,
29    /// External task scheduler service (has `Scheduler` capability).
30    Scheduler,
31    /// Unrecognized capability combination.
32    Unknown,
33}
34
35impl ServiceProfile {
36    /// Derive the behavioral profile from a capability set.
37    ///
38    /// Precedence: `UpdateTracker` > `Scheduler` > `Agent` > `Unknown`.
39    pub fn from_capabilities(caps: &BTreeSet<Capability>) -> Self {
40        if caps.contains(&Capability::UpdateTracking) {
41            Self::UpdateTracker
42        } else if caps.contains(&Capability::Scheduler) {
43            Self::Scheduler
44        } else if caps.contains(&Capability::SoftwareDiscovery) {
45            Self::Agent
46        } else {
47            Self::Unknown
48        }
49    }
50
51    /// Default ping interval in seconds for this profile.
52    ///
53    /// - `UpdateTracker`: 15 seconds (MQTT lease heartbeat).
54    /// - `Scheduler`: 60 seconds (less latency-sensitive).
55    /// - `Agent` / `Unknown`: 300 seconds (5 minutes).
56    pub const fn default_ping_interval_secs(&self) -> u32 {
57        match self {
58            Self::UpdateTracker => 15,
59            Self::Scheduler => 60,
60            Self::Agent | Self::Unknown => 300,
61        }
62    }
63
64    /// Shutdown timeout in seconds, if applicable.
65    ///
66    /// - `UpdateTracker`: `None` (no graceful shutdown timeout).
67    /// - `Scheduler`: `Some(30)` (allow claim release).
68    /// - `Agent` / `Unknown`: `Some(120)` (2 minutes).
69    pub const fn shutdown_timeout_secs(&self) -> Option<u32> {
70        match self {
71            Self::UpdateTracker => None,
72            Self::Scheduler => Some(30),
73            Self::Agent | Self::Unknown => Some(120),
74        }
75    }
76
77    /// Human-readable label for display in the API and UI.
78    ///
79    /// For `Agent` profiles, pass `has_ssh_remote = true` to distinguish
80    /// SSH-backed agents from local agents.
81    pub const fn service_label(&self, has_ssh_remote: bool) -> &'static str {
82        match self {
83            Self::UpdateTracker => "Update Tracker",
84            Self::Scheduler => "Scheduler",
85            Self::Agent if has_ssh_remote => "SSH Agent",
86            Self::Agent => "Agent",
87            Self::Unknown => "Unknown",
88        }
89    }
90}
91
92/// Parse a JSON array string into a capability set.
93///
94/// The JSON is expected to be an array of snake_case strings
95/// (e.g. `["software_discovery","update_hooks","graceful_shutdown"]`).
96/// Returns an empty set on parse failure.
97pub fn parse_capabilities(json: &str) -> BTreeSet<Capability> {
98    serde_json::from_str::<Vec<Capability>>(json)
99        .unwrap_or_default()
100        .into_iter()
101        .collect()
102}
103
104/// Serialize a capability set into a JSON array string.
105///
106/// Produces a sorted JSON array of snake_case strings
107/// (e.g. `["graceful_shutdown","software_discovery","update_hooks"]`).
108/// `BTreeSet` iteration order guarantees deterministic output.
109pub fn serialize_capabilities(caps: &BTreeSet<Capability>) -> String {
110    let vec: Vec<&Capability> = caps.iter().collect();
111    serde_json::to_string(&vec).unwrap_or_else(|_| "[]".to_string())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn caps(items: &[Capability]) -> BTreeSet<Capability> {
119        items.iter().cloned().collect()
120    }
121
122    // ---------------------------------------------------------------
123    // ServiceProfile::from_capabilities
124    // ---------------------------------------------------------------
125
126    #[test]
127    fn profile_update_tracker() {
128        let c = caps(&[Capability::UpdateTracking, Capability::GracefulShutdown]);
129        assert_eq!(
130            ServiceProfile::from_capabilities(&c),
131            ServiceProfile::UpdateTracker
132        );
133    }
134
135    #[test]
136    fn profile_agent() {
137        let c = caps(&[
138            Capability::SoftwareDiscovery,
139            Capability::UpdateHooks,
140            Capability::GracefulShutdown,
141        ]);
142        assert_eq!(ServiceProfile::from_capabilities(&c), ServiceProfile::Agent);
143    }
144
145    #[test]
146    fn profile_ssh_agent() {
147        let c = caps(&[
148            Capability::SoftwareDiscovery,
149            Capability::SshRemote,
150            Capability::UpdateHooks,
151            Capability::GracefulShutdown,
152        ]);
153        assert_eq!(ServiceProfile::from_capabilities(&c), ServiceProfile::Agent);
154    }
155
156    #[test]
157    fn profile_scheduler() {
158        let c = caps(&[
159            Capability::Scheduler,
160            Capability::DatabaseAccess,
161            Capability::NatsAccess,
162            Capability::GracefulShutdown,
163        ]);
164        assert_eq!(
165            ServiceProfile::from_capabilities(&c),
166            ServiceProfile::Scheduler
167        );
168    }
169
170    #[test]
171    fn scheduler_takes_precedence_over_agent() {
172        let c = caps(&[Capability::Scheduler, Capability::SoftwareDiscovery]);
173        assert_eq!(
174            ServiceProfile::from_capabilities(&c),
175            ServiceProfile::Scheduler
176        );
177    }
178
179    #[test]
180    fn update_tracker_takes_precedence_over_scheduler() {
181        let c = caps(&[Capability::UpdateTracking, Capability::Scheduler]);
182        assert_eq!(
183            ServiceProfile::from_capabilities(&c),
184            ServiceProfile::UpdateTracker
185        );
186    }
187
188    #[test]
189    fn profile_unknown_empty() {
190        assert_eq!(
191            ServiceProfile::from_capabilities(&BTreeSet::new()),
192            ServiceProfile::Unknown
193        );
194    }
195
196    #[test]
197    fn profile_unknown_only_graceful_shutdown() {
198        let c = caps(&[Capability::GracefulShutdown]);
199        assert_eq!(
200            ServiceProfile::from_capabilities(&c),
201            ServiceProfile::Unknown
202        );
203    }
204
205    #[test]
206    fn update_tracker_takes_precedence() {
207        let c = caps(&[Capability::UpdateTracking, Capability::SoftwareDiscovery]);
208        assert_eq!(
209            ServiceProfile::from_capabilities(&c),
210            ServiceProfile::UpdateTracker
211        );
212    }
213
214    // ---------------------------------------------------------------
215    // Ping intervals
216    // ---------------------------------------------------------------
217
218    #[test]
219    fn ping_interval_update_tracker() {
220        assert_eq!(
221            ServiceProfile::UpdateTracker.default_ping_interval_secs(),
222            15
223        );
224    }
225
226    #[test]
227    fn ping_interval_agent() {
228        assert_eq!(ServiceProfile::Agent.default_ping_interval_secs(), 300);
229    }
230
231    #[test]
232    fn ping_interval_scheduler() {
233        assert_eq!(ServiceProfile::Scheduler.default_ping_interval_secs(), 60);
234    }
235
236    #[test]
237    fn ping_interval_unknown() {
238        assert_eq!(ServiceProfile::Unknown.default_ping_interval_secs(), 300);
239    }
240
241    // ---------------------------------------------------------------
242    // Shutdown timeouts
243    // ---------------------------------------------------------------
244
245    #[test]
246    fn shutdown_timeout_update_tracker() {
247        assert_eq!(ServiceProfile::UpdateTracker.shutdown_timeout_secs(), None);
248    }
249
250    #[test]
251    fn shutdown_timeout_agent() {
252        assert_eq!(ServiceProfile::Agent.shutdown_timeout_secs(), Some(120));
253    }
254
255    #[test]
256    fn shutdown_timeout_scheduler() {
257        assert_eq!(ServiceProfile::Scheduler.shutdown_timeout_secs(), Some(30));
258    }
259
260    #[test]
261    fn shutdown_timeout_unknown() {
262        assert_eq!(ServiceProfile::Unknown.shutdown_timeout_secs(), Some(120));
263    }
264
265    // ---------------------------------------------------------------
266    // Service labels
267    // ---------------------------------------------------------------
268
269    #[test]
270    fn label_agent() {
271        assert_eq!(ServiceProfile::Agent.service_label(false), "Agent");
272    }
273
274    #[test]
275    fn label_ssh_agent() {
276        assert_eq!(ServiceProfile::Agent.service_label(true), "SSH Agent");
277    }
278
279    #[test]
280    fn label_update_tracker() {
281        assert_eq!(
282            ServiceProfile::UpdateTracker.service_label(false),
283            "Update Tracker"
284        );
285    }
286
287    #[test]
288    fn label_scheduler() {
289        assert_eq!(ServiceProfile::Scheduler.service_label(false), "Scheduler");
290    }
291
292    #[test]
293    fn label_unknown() {
294        assert_eq!(ServiceProfile::Unknown.service_label(false), "Unknown");
295    }
296
297    // ---------------------------------------------------------------
298    // Capability JSON round-trip
299    // ---------------------------------------------------------------
300
301    #[test]
302    fn serialize_empty_set() {
303        assert_eq!(serialize_capabilities(&BTreeSet::new()), "[]");
304    }
305
306    #[test]
307    fn serialize_and_parse_round_trip() {
308        let original = caps(&[
309            Capability::GracefulShutdown,
310            Capability::SoftwareDiscovery,
311            Capability::UpdateHooks,
312        ]);
313        let json = serialize_capabilities(&original);
314        let parsed = parse_capabilities(&json);
315        assert_eq!(parsed, original);
316    }
317
318    #[test]
319    fn parse_invalid_json_returns_empty() {
320        assert!(parse_capabilities("not json").is_empty());
321    }
322
323    #[test]
324    fn parse_empty_array() {
325        assert!(parse_capabilities("[]").is_empty());
326    }
327
328    #[test]
329    fn parse_preserves_all_known_capabilities() {
330        let json = r#"["graceful_shutdown","software_discovery","ssh_remote","update_hooks","update_tracking"]"#;
331        let parsed = parse_capabilities(json);
332        assert_eq!(parsed.len(), 5);
333        assert!(parsed.contains(&Capability::GracefulShutdown));
334        assert!(parsed.contains(&Capability::UpdateTracking));
335        assert!(parsed.contains(&Capability::SoftwareDiscovery));
336        assert!(parsed.contains(&Capability::SshRemote));
337        assert!(parsed.contains(&Capability::UpdateHooks));
338    }
339
340    #[test]
341    fn parse_unknown_capabilities_become_other() {
342        let json = r#"["software_discovery","future_cap"]"#;
343        let parsed = parse_capabilities(json);
344        assert_eq!(parsed.len(), 2);
345        assert!(parsed.contains(&Capability::SoftwareDiscovery));
346        assert!(parsed.contains(&Capability::Other("future_cap".to_string())));
347    }
348
349    #[test]
350    fn serialize_produces_sorted_output() {
351        let c = caps(&[
352            Capability::UpdateHooks,
353            Capability::GracefulShutdown,
354            Capability::SoftwareDiscovery,
355        ]);
356        let json = serialize_capabilities(&c);
357        // BTreeSet sorts by Ord impl; verify output is deterministic.
358        let parsed: Vec<String> = serde_json::from_str(&json).unwrap_or_default();
359        let mut sorted = parsed.clone();
360        sorted.sort();
361        assert_eq!(parsed, sorted);
362    }
363}