Skip to main content

uptrakit_web_api_types/
services.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7// Canonical types from shared-types with feature-gated OpenAPI derives.
8pub use uptrakit_shared_types::{ParseServiceStatusError, ServiceStatus};
9
10/// Unified response for any service (agent or MQTT).
11#[non_exhaustive]
12#[derive(Debug, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14pub struct ServiceResponse {
15    pub id: Uuid,
16    pub capabilities: Vec<String>,
17    pub service_label: String,
18    pub hostname: String,
19    pub friendly_name: String,
20    pub is_embedded: bool,
21    pub ip_address: Option<String>,
22    pub status: ServiceStatus,
23    pub client_version: Option<String>,
24    #[serde(with = "time::serde::rfc3339::option")]
25    #[cfg_attr(
26        feature = "openapi",
27        schema(value_type = Option<String>, format = DateTime)
28    )]
29    pub last_seen_at: Option<OffsetDateTime>,
30    #[serde(with = "time::serde::rfc3339")]
31    #[cfg_attr(
32        feature = "openapi",
33        schema(value_type = String, format = DateTime)
34    )]
35    pub created_at: OffsetDateTime,
36    #[serde(with = "time::serde::rfc3339")]
37    #[cfg_attr(
38        feature = "openapi",
39        schema(value_type = String, format = DateTime)
40    )]
41    pub updated_at: OffsetDateTime,
42    /// Custom ping interval override in seconds. `None` means the
43    /// service-profile default is used.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub ping_interval_seconds: Option<u32>,
46    /// Per-service certificate lifetime override in hours. `None` means the
47    /// global default is used.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub cert_lifetime_hours: Option<u32>,
50    /// External service IDs currently causing this embedded service to yield.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub yielded_to: Option<Vec<Uuid>>,
53    /// SPIFFE identity URI from the service's current certificate.
54    ///
55    /// Present only when the controller has a trust domain configured and the
56    /// service certificate contains a SPIFFE URI SAN.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub spiffe_id: Option<String>,
59    /// Serial number of the most recent non-revoked service certificate.
60    ///
61    /// Populated only on the detail endpoint (`GET /api/v1/services/{id}`).
62    /// Absent (`None`) on the list endpoint to avoid N+1 queries.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub cert_serial_number: Option<String>,
65}
66
67impl ServiceResponse {
68    #[expect(
69        clippy::too_many_arguments,
70        reason = "ServiceResponse has 17 fields; all are required at construction"
71    )]
72    pub fn new(
73        id: Uuid,
74        capabilities: Vec<String>,
75        service_label: String,
76        hostname: String,
77        friendly_name: String,
78        is_embedded: bool,
79        ip_address: Option<String>,
80        status: ServiceStatus,
81        client_version: Option<String>,
82        last_seen_at: Option<OffsetDateTime>,
83        created_at: OffsetDateTime,
84        updated_at: OffsetDateTime,
85        ping_interval_seconds: Option<u32>,
86        cert_lifetime_hours: Option<u32>,
87        yielded_to: Option<Vec<Uuid>>,
88        spiffe_id: Option<String>,
89        cert_serial_number: Option<String>,
90    ) -> Self {
91        Self {
92            id,
93            capabilities,
94            service_label,
95            hostname,
96            friendly_name,
97            is_embedded,
98            ip_address,
99            status,
100            client_version,
101            last_seen_at,
102            created_at,
103            updated_at,
104            ping_interval_seconds,
105            cert_lifetime_hours,
106            yielded_to,
107            spiffe_id,
108            cert_serial_number,
109        }
110    }
111}
112
113/// Query parameters for listing services.
114#[derive(Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
116pub struct ListServicesQuery {
117    /// Filter by capability.
118    pub capability: Option<String>,
119    /// Filter by status: `pending`, `approved`, `rejected`, `deactivated`.
120    pub status: Option<ServiceStatus>,
121    /// Page number (1-indexed). Defaults to 1.
122    pub page: Option<u64>,
123    /// Items per page. Defaults to 20, max 1000.
124    pub per_page: Option<u64>,
125}
126
127impl ListServicesQuery {
128    pub fn pagination(&self) -> crate::pagination::PaginationParams {
129        crate::pagination::PaginationParams {
130            page: self.page,
131            per_page: self.per_page,
132        }
133    }
134}
135
136/// Request to update a service's configurable settings.
137#[derive(Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
139pub struct UpdateServiceRequest {
140    /// Custom ping interval in seconds.
141    /// Omit to keep current value. Set to `0` to clear the override and
142    /// revert to the service-type default. Set to a positive value to
143    /// override the default.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub ping_interval_seconds: Option<u32>,
146    /// Per-service certificate lifetime in hours.
147    /// Omit to keep current value. Set to `0` to clear the override and revert
148    /// to the global default. Set to a positive value (1–17520) to override.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub cert_lifetime_hours: Option<u32>,
151}
152
153impl Validate for UpdateServiceRequest {
154    fn validate(&self) -> Result<(), ValidationError> {
155        // 0 is a sentinel meaning "clear the override"; any positive value
156        // must be at least 5 seconds to avoid excessive polling.
157        if let Some(interval) = self.ping_interval_seconds
158            && interval != 0
159            && interval < 5
160        {
161            return Err(ValidationError {
162                field: "ping_interval_seconds",
163                message: "ping_interval_seconds must be 0 (to clear) or at least 5".to_string(),
164            });
165        }
166        if let Some(hours) = self.cert_lifetime_hours
167            && hours != 0
168            && !(1..=17_520u32).contains(&hours)
169        {
170            return Err(ValidationError {
171                field: "cert_lifetime_hours",
172                message: "cert_lifetime_hours must be 0 (to clear) or between 1 and 17520"
173                    .to_string(),
174            });
175        }
176        Ok(())
177    }
178}
179
180/// Request to enable or disable the update freeze on a connected service.
181#[derive(Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183pub struct SetUpdateFreezeRequest {
184    /// Whether to enable (`true`) or disable (`false`) the update freeze.
185    pub enabled: bool,
186    /// Optional human-readable reason for the freeze.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub reason: Option<String>,
189}
190
191impl Validate for SetUpdateFreezeRequest {
192    fn validate(&self) -> Result<(), ValidationError> {
193        if let Some(ref reason) = self.reason
194            && reason.len() > 1024
195        {
196            return Err(ValidationError {
197                field: "reason",
198                message: "reason must be at most 1024 characters".to_string(),
199            });
200        }
201        Ok(())
202    }
203}
204
205// Re-export generic types that are shared across service operations.
206pub use super::agents::{MergeAgentRequest, MessageResponse};
207
208#[cfg(test)]
209mod tests {
210    #![expect(
211        clippy::assertions_on_result_states,
212        reason = "test assertions — is_ok/is_err provides readable failure messages"
213    )]
214    use super::*;
215    use time::macros::datetime;
216
217    fn sample_uuid() -> Uuid {
218        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
219            .expect("hard-coded UUID should be valid")
220    }
221
222    // ── ServiceResponse ──────────────────────────────────────────────
223
224    #[test]
225    fn service_response_round_trip_all_fields() {
226        let resp = ServiceResponse {
227            id: sample_uuid(),
228            capabilities: vec![
229                "software_discovery".into(),
230                "update_hooks".into(),
231                "graceful_shutdown".into(),
232            ],
233            service_label: "Agent".into(),
234            hostname: "host-1.local".to_string(),
235            friendly_name: "My Agent".to_string(),
236            is_embedded: false,
237            ip_address: Some("10.0.0.1".to_string()),
238            status: ServiceStatus::Approved,
239            client_version: Some("1.2.3".to_string()),
240            last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
241            created_at: datetime!(2025-01-01 0:00:00 UTC),
242            updated_at: datetime!(2025-06-01 12:00:00 UTC),
243            ping_interval_seconds: Some(60),
244            cert_lifetime_hours: None,
245            yielded_to: None,
246            spiffe_id: None,
247            cert_serial_number: None,
248        };
249        let json = serde_json::to_string(&resp).expect("serialization should succeed");
250        let deserialized: ServiceResponse =
251            serde_json::from_str(&json).expect("deserialization should succeed");
252        assert_eq!(deserialized.id, sample_uuid());
253        assert_eq!(
254            deserialized.capabilities,
255            vec!["software_discovery", "update_hooks", "graceful_shutdown"]
256        );
257        assert_eq!(deserialized.service_label, "Agent");
258        assert_eq!(deserialized.hostname, "host-1.local");
259        assert_eq!(deserialized.friendly_name, "My Agent");
260        assert!(!deserialized.is_embedded);
261        assert_eq!(deserialized.ip_address.as_deref(), Some("10.0.0.1"));
262        assert_eq!(deserialized.status, ServiceStatus::Approved);
263        assert_eq!(deserialized.client_version.as_deref(), Some("1.2.3"));
264        assert!(deserialized.last_seen_at.is_some());
265        assert_eq!(deserialized.ping_interval_seconds, Some(60));
266    }
267
268    #[test]
269    fn service_response_round_trip_none_fields() {
270        let resp = ServiceResponse {
271            id: sample_uuid(),
272            capabilities: vec!["update_tracking".into(), "graceful_shutdown".into()],
273            service_label: "Update Tracker".into(),
274            hostname: "mqtt-broker".to_string(),
275            friendly_name: "MQTT Service".to_string(),
276            is_embedded: false,
277            ip_address: None,
278            status: ServiceStatus::Pending,
279            client_version: None,
280            last_seen_at: None,
281            created_at: datetime!(2025-01-01 0:00:00 UTC),
282            updated_at: datetime!(2025-01-01 0:00:00 UTC),
283            ping_interval_seconds: None,
284            cert_lifetime_hours: None,
285            yielded_to: None,
286            spiffe_id: None,
287            cert_serial_number: None,
288        };
289        let json = serde_json::to_string(&resp).expect("serialization should succeed");
290        let deserialized: ServiceResponse =
291            serde_json::from_str(&json).expect("deserialization should succeed");
292        assert!(deserialized.ip_address.is_none());
293        assert!(deserialized.client_version.is_none());
294        assert!(deserialized.last_seen_at.is_none());
295        assert_eq!(deserialized.status, ServiceStatus::Pending);
296        assert!(deserialized.ping_interval_seconds.is_none());
297    }
298
299    #[test]
300    fn service_response_ssh_agent_type() {
301        let resp = ServiceResponse {
302            id: sample_uuid(),
303            capabilities: vec![
304                "ssh_remote".into(),
305                "software_discovery".into(),
306                "update_hooks".into(),
307                "graceful_shutdown".into(),
308            ],
309            service_label: "SSH Agent".into(),
310            hostname: "ssh-host".to_string(),
311            friendly_name: "SSH Agent".to_string(),
312            is_embedded: false,
313            ip_address: None,
314            status: ServiceStatus::Deactivated,
315            client_version: None,
316            last_seen_at: None,
317            created_at: datetime!(2025-01-01 0:00:00 UTC),
318            updated_at: datetime!(2025-01-01 0:00:00 UTC),
319            ping_interval_seconds: None,
320            cert_lifetime_hours: None,
321            yielded_to: None,
322            spiffe_id: None,
323            cert_serial_number: None,
324        };
325        let json_value =
326            serde_json::to_value(&resp).expect("serialization to Value should succeed");
327        assert!(json_value.get("capabilities").is_some());
328        assert_eq!(
329            json_value.get("service_label").and_then(|v| v.as_str()),
330            Some("SSH Agent")
331        );
332        assert_eq!(
333            json_value.get("status").and_then(|v| v.as_str()),
334            Some("deactivated")
335        );
336    }
337
338    // ── ListServicesQuery ────────────────────────────────────────────
339
340    #[test]
341    fn list_services_query_round_trip_all_fields() {
342        let query = ListServicesQuery {
343            capability: Some("software_discovery".into()),
344            status: Some(ServiceStatus::Approved),
345            page: Some(2),
346            per_page: Some(50),
347        };
348        let json = serde_json::to_string(&query).expect("serialization should succeed");
349        let deserialized: ListServicesQuery =
350            serde_json::from_str(&json).expect("deserialization should succeed");
351        assert_eq!(
352            deserialized.capability.as_deref(),
353            Some("software_discovery")
354        );
355        assert_eq!(deserialized.status, Some(ServiceStatus::Approved));
356        assert_eq!(deserialized.page, Some(2));
357        assert_eq!(deserialized.per_page, Some(50));
358    }
359
360    #[test]
361    fn list_services_query_round_trip_none_fields() {
362        let query = ListServicesQuery {
363            capability: None,
364            status: None,
365            page: None,
366            per_page: None,
367        };
368        let json = serde_json::to_string(&query).expect("serialization should succeed");
369        let deserialized: ListServicesQuery =
370            serde_json::from_str(&json).expect("deserialization should succeed");
371        assert!(deserialized.capability.is_none());
372        assert!(deserialized.status.is_none());
373        assert!(deserialized.page.is_none());
374        assert!(deserialized.per_page.is_none());
375    }
376
377    // ── ListServicesQuery::pagination() ──────────────────────────────
378
379    #[test]
380    fn pagination_returns_page_and_per_page() {
381        let query = ListServicesQuery {
382            capability: None,
383            status: None,
384            page: Some(3),
385            per_page: Some(25),
386        };
387        let params = query.pagination();
388        assert_eq!(params.page, Some(3));
389        assert_eq!(params.per_page, Some(25));
390    }
391
392    #[test]
393    fn pagination_returns_none_when_not_set() {
394        let query = ListServicesQuery {
395            capability: None,
396            status: None,
397            page: None,
398            per_page: None,
399        };
400        let params = query.pagination();
401        assert!(params.page.is_none());
402        assert!(params.per_page.is_none());
403    }
404
405    #[test]
406    fn pagination_resolve_applies_defaults() {
407        let query = ListServicesQuery {
408            capability: None,
409            status: None,
410            page: None,
411            per_page: None,
412        };
413        let resolved = query.pagination().resolve();
414        assert_eq!(resolved.page, 1);
415        assert_eq!(resolved.per_page, crate::pagination::DEFAULT_PER_PAGE);
416    }
417
418    // ── UpdateServiceRequest ─────────────────────────────────────────
419
420    #[test]
421    fn update_service_request_with_ping_interval() {
422        let req = UpdateServiceRequest {
423            ping_interval_seconds: Some(60),
424            cert_lifetime_hours: None,
425        };
426        let json = serde_json::to_string(&req).expect("serialization should succeed");
427        assert!(json.contains(r#""ping_interval_seconds":60"#));
428        let parsed: UpdateServiceRequest =
429            serde_json::from_str(&json).expect("deserialization should succeed");
430        assert_eq!(parsed.ping_interval_seconds, Some(60));
431    }
432
433    #[test]
434    fn update_service_request_without_ping_interval() {
435        let req = UpdateServiceRequest {
436            ping_interval_seconds: None,
437            cert_lifetime_hours: None,
438        };
439        let json = serde_json::to_string(&req).expect("serialization should succeed");
440        assert!(!json.contains("ping_interval_seconds"));
441    }
442
443    #[test]
444    fn update_service_request_clear_with_zero() {
445        let json = r#"{"ping_interval_seconds":0}"#;
446        let parsed: UpdateServiceRequest =
447            serde_json::from_str(json).expect("deserialization should succeed");
448        assert_eq!(parsed.ping_interval_seconds, Some(0));
449    }
450
451    // ── Validate ─────────────────────────────────────────────────────
452
453    #[test]
454    fn validate_accepts_none_interval() {
455        let req = UpdateServiceRequest {
456            ping_interval_seconds: None,
457            cert_lifetime_hours: None,
458        };
459        assert!(req.validate().is_ok());
460    }
461
462    #[test]
463    fn validate_accepts_zero_interval_as_clear_sentinel() {
464        let req = UpdateServiceRequest {
465            ping_interval_seconds: Some(0),
466            cert_lifetime_hours: None,
467        };
468        assert!(req.validate().is_ok());
469    }
470
471    #[test]
472    fn validate_accepts_interval_of_five_or_more() {
473        for v in [5u32, 10, 60, 3600] {
474            let req = UpdateServiceRequest {
475                ping_interval_seconds: Some(v),
476                cert_lifetime_hours: None,
477            };
478            assert!(req.validate().is_ok(), "expected ok for {v}");
479        }
480    }
481
482    #[test]
483    fn validate_rejects_interval_below_five() {
484        for v in [1u32, 2, 3, 4] {
485            let req = UpdateServiceRequest {
486                ping_interval_seconds: Some(v),
487                cert_lifetime_hours: None,
488            };
489            let err = req.validate().unwrap_err();
490            assert_eq!(err.field, "ping_interval_seconds", "field mismatch for {v}");
491        }
492    }
493
494    // ── cert_lifetime_hours ───────────────────────────────────────────
495
496    #[test]
497    fn service_response_includes_cert_lifetime_hours() {
498        let resp = ServiceResponse {
499            id: sample_uuid(),
500            capabilities: vec!["graceful_shutdown".into()],
501            service_label: "Agent".into(),
502            hostname: "host".to_string(),
503            friendly_name: "H".to_string(),
504            is_embedded: true,
505            ip_address: None,
506            status: ServiceStatus::Approved,
507            client_version: None,
508            last_seen_at: None,
509            created_at: datetime!(2025-01-01 0:00:00 UTC),
510            updated_at: datetime!(2025-01-01 0:00:00 UTC),
511            ping_interval_seconds: None,
512            cert_lifetime_hours: Some(48),
513            yielded_to: Some(vec![sample_uuid()]),
514            spiffe_id: None,
515            cert_serial_number: None,
516        };
517        let json = serde_json::to_string(&resp).expect("serialization should succeed");
518        assert!(json.contains(r#""cert_lifetime_hours":48"#));
519        let de: ServiceResponse =
520            serde_json::from_str(&json).expect("deserialization should succeed");
521        assert!(de.is_embedded);
522        assert_eq!(de.cert_lifetime_hours, Some(48));
523        assert_eq!(de.yielded_to, Some(vec![sample_uuid()]));
524    }
525
526    #[test]
527    fn service_response_omits_cert_lifetime_hours_when_none() {
528        let resp = ServiceResponse {
529            id: sample_uuid(),
530            capabilities: vec!["graceful_shutdown".into()],
531            service_label: "Agent".into(),
532            hostname: "host".to_string(),
533            friendly_name: "H".to_string(),
534            is_embedded: false,
535            ip_address: None,
536            status: ServiceStatus::Approved,
537            client_version: None,
538            last_seen_at: None,
539            created_at: datetime!(2025-01-01 0:00:00 UTC),
540            updated_at: datetime!(2025-01-01 0:00:00 UTC),
541            ping_interval_seconds: None,
542            cert_lifetime_hours: None,
543            yielded_to: None,
544            spiffe_id: None,
545            cert_serial_number: None,
546        };
547        let json = serde_json::to_string(&resp).expect("serialization should succeed");
548        assert!(!json.contains("cert_lifetime_hours"));
549        assert!(!json.contains("yielded_to"));
550    }
551
552    #[test]
553    fn update_service_request_with_cert_lifetime_hours() {
554        let req = UpdateServiceRequest {
555            ping_interval_seconds: None,
556            cert_lifetime_hours: Some(48),
557        };
558        let json = serde_json::to_string(&req).expect("serialization should succeed");
559        assert!(json.contains(r#""cert_lifetime_hours":48"#));
560        let parsed: UpdateServiceRequest =
561            serde_json::from_str(&json).expect("deserialization should succeed");
562        assert_eq!(parsed.cert_lifetime_hours, Some(48));
563    }
564
565    #[test]
566    fn update_service_request_clear_cert_lifetime_with_zero() {
567        let json = r#"{"cert_lifetime_hours":0}"#;
568        let parsed: UpdateServiceRequest =
569            serde_json::from_str(json).expect("deserialization should succeed");
570        assert_eq!(parsed.cert_lifetime_hours, Some(0));
571        assert!(parsed.validate().is_ok());
572    }
573
574    #[test]
575    fn validate_accepts_cert_lifetime_hours_in_range() {
576        for v in [1u32, 12, 48, 168, 17_520] {
577            let req = UpdateServiceRequest {
578                ping_interval_seconds: None,
579                cert_lifetime_hours: Some(v),
580            };
581            assert!(req.validate().is_ok(), "expected ok for {v}");
582        }
583    }
584
585    #[test]
586    fn validate_rejects_cert_lifetime_hours_above_max() {
587        let req = UpdateServiceRequest {
588            ping_interval_seconds: None,
589            cert_lifetime_hours: Some(17_521),
590        };
591        let err = req.validate().unwrap_err();
592        assert_eq!(err.field, "cert_lifetime_hours");
593    }
594}