Skip to main content

uptrakit_wire/
capabilities.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6/// A protocol capability advertised by a service or controller during connection setup.
7///
8/// Both sides announce their capability sets at the start of each authenticated
9/// connection. Each side independently computes the agreed set as the intersection
10/// of typed variants only — [`Other`](Self::Other) is excluded from intersection.
11///
12/// ## Wire format
13///
14/// Capabilities are serialized as plain strings (snake_case). Unknown strings from
15/// a newer peer become `Other(String)` for forward compatibility.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[cfg_attr(feature = "schema", derive(strum::EnumIter))]
18#[non_exhaustive]
19pub enum Capability {
20    /// Service participates in the graceful-shutdown protocol: sends
21    /// `Disconnecting` before clean exit and honours
22    /// `shutdown_timeout_seconds` from `ServiceSettings`.
23    ///
24    /// Wire string: `graceful_shutdown`.
25    GracefulShutdown,
26    /// Service tracks software update state and host connectivity.
27    ///
28    /// The controller uses this capability to route software-state broadcasts
29    /// and connectivity updates. Also gates MQTT-specific lease coordination
30    /// for MQTT bridge services.
31    ///
32    /// Wire string: `update_tracking`.
33    UpdateTracking,
34    /// Service supports `DiscoverSoftware` → `DiscoveryResults` flow.
35    ///
36    /// The controller gates autodiscovery requests on this capability.
37    ///
38    /// Wire string: `software_discovery`.
39    SoftwareDiscovery,
40    /// Service manages remote hosts over SSH, rather than running locally.
41    ///
42    /// Identifies an SSH-backed agent. Combined with `SoftwareDiscovery`,
43    /// uniquely identifies an SSH agent (vs. a local agent).
44    ///
45    /// Wire string: `ssh_remote`.
46    SshRemote,
47    /// Service supports pre-/post-update lifecycle hook plugins
48    /// (`PluginAssignment` in `ExecuteUpdatePayload`). The controller omits
49    /// hook plugins when absent.
50    ///
51    /// Wire string: `update_hooks`.
52    UpdateHooks,
53    /// Marker: service is an external task scheduler.
54    ///
55    /// Identifies a service that runs scheduled tasks (version checks, cert
56    /// checks, auth cleanup, etc.) externally. The controller uses this to
57    /// detect scheduler presence and disable the embedded scheduler.
58    ///
59    /// Wire string: `scheduler`.
60    Scheduler,
61    /// Service requires direct database access. The controller will include
62    /// `db_url` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload).
63    ///
64    /// Wire string: `database_access`.
65    DatabaseAccess,
66    /// Service requires NATS access. The controller will include `nats_url`
67    /// in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if NATS is configured).
68    ///
69    /// Wire string: `nats_access`.
70    NatsAccess,
71    /// Service requires the master encryption key. The controller will include
72    /// `master_key_hex` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if encryption is enabled).
73    ///
74    /// Wire string: `master_key_access`.
75    MasterKeyAccess,
76    /// Service can request CA certificate rotation via [`RequestCaRotationPayload`](super::payloads::RequestCaRotationPayload).
77    /// The controller will accept `RequestCaRotation` messages from services
78    /// with this capability (via NATS or local delivery).
79    ///
80    /// Wire string: `ca_management`.
81    CaManagement,
82    /// Service is a global infrastructure service, not bound to any tenant.
83    ///
84    /// When present in an `EnrollPayload`, the controller routes enrollment to
85    /// the `system_services` table instead of the per-tenant `services` table.
86    ///
87    /// **Credential guard**: any service requesting `DatabaseAccess`,
88    /// `NatsAccess`, `MasterKeyAccess`, or `CaManagement` without also
89    /// advertising `SystemService` will be rejected at enrollment with a 403
90    /// error. This prevents regular tenant agents from claiming infrastructure
91    /// credentials.
92    ///
93    /// Wire string: `system_service`.
94    SystemService,
95    /// Service supports UI surfaces: it will send `SurfaceRegistration` after
96    /// connection and respond to surface action messages.
97    ///
98    /// Wire string: `ui_surfaces`.
99    UiSurfaces,
100    /// Service supports interactive update sessions: PTY allocation, stdin
101    /// forwarding, and signal delivery during update execution.
102    ///
103    /// When present, the controller may set `interactive: true` on
104    /// `ExecuteUpdatePayload` and send `UpdateStdinData` messages to this
105    /// service. The service allocates a PTY for the update process and keeps
106    /// stdin open for forwarding.
107    ///
108    /// Wire string: `interactive_updates`.
109    InteractiveUpdates,
110    /// Service supports the reset-data protocol: truncates local data stores
111    /// when the controller broadcasts a data reset.
112    ///
113    /// Wire string: `reset_data`.
114    ResetData,
115    /// Service participates in the workload claim protocol for exclusive
116    /// config-key ownership.
117    ///
118    /// Services with this capability send `WorkloadClaim` after receiving
119    /// `ServiceConfigDelivery` to request exclusive ownership of config keys.
120    /// The controller responds with `WorkloadClaimResult` and routes
121    /// tenant-scoped messages only to services that hold granted claims.
122    ///
123    /// Wire string: `workload_claims`.
124    WorkloadClaims,
125    /// Unknown capability from a newer peer; never participates in intersection.
126    ///
127    /// Provides forward compatibility: a newer peer may advertise capabilities
128    /// that an older build does not yet recognise. These are preserved on receipt
129    /// but never emitted by the current codebase.
130    Other(String),
131}
132
133impl Capability {
134    /// Returns the snake_case wire string for this capability.
135    pub fn as_str(&self) -> &str {
136        match self {
137            Self::SoftwareDiscovery => "software_discovery",
138            Self::UpdateHooks => "update_hooks",
139            Self::GracefulShutdown => "graceful_shutdown",
140            Self::UpdateTracking => "update_tracking",
141            Self::SshRemote => "ssh_remote",
142            Self::Scheduler => "scheduler",
143            Self::DatabaseAccess => "database_access",
144            Self::NatsAccess => "nats_access",
145            Self::MasterKeyAccess => "master_key_access",
146            Self::CaManagement => "ca_management",
147            Self::SystemService => "system_service",
148            Self::UiSurfaces => "ui_surfaces",
149            Self::InteractiveUpdates => "interactive_updates",
150            Self::ResetData => "reset_data",
151            Self::WorkloadClaims => "workload_claims",
152            Self::Other(s) => s.as_str(),
153        }
154    }
155
156    /// Returns `true` for typed variants; `Other` returns `false`.
157    ///
158    /// Only typed variants participate in capability intersection. `Other` values
159    /// are forwarded-compatibility markers and must not gate behaviour.
160    pub fn is_known(&self) -> bool {
161        !matches!(self, Self::Other(_))
162    }
163}
164
165impl fmt::Display for Capability {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171impl FromStr for Capability {
172    type Err = std::convert::Infallible;
173
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        Ok(match s {
176            "software_discovery" => Self::SoftwareDiscovery,
177            "update_hooks" => Self::UpdateHooks,
178            "graceful_shutdown" => Self::GracefulShutdown,
179            "update_tracking" => Self::UpdateTracking,
180            "ssh_remote" => Self::SshRemote,
181            "scheduler" => Self::Scheduler,
182            "database_access" => Self::DatabaseAccess,
183            "nats_access" => Self::NatsAccess,
184            "master_key_access" => Self::MasterKeyAccess,
185            "ca_management" => Self::CaManagement,
186            "system_service" => Self::SystemService,
187            "ui_surfaces" => Self::UiSurfaces,
188            "interactive_updates" => Self::InteractiveUpdates,
189            "reset_data" => Self::ResetData,
190            "workload_claims" => Self::WorkloadClaims,
191            other => {
192                tracing::debug!(capability = other, "received unknown capability from peer");
193                Self::Other(other.to_string())
194            }
195        })
196    }
197}
198
199impl Serialize for Capability {
200    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
201        serializer.serialize_str(self.as_str())
202    }
203}
204
205impl<'de> Deserialize<'de> for Capability {
206    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
207        let s = String::deserialize(deserializer)?;
208        Ok(s.parse().unwrap_or(Capability::Other(s)))
209    }
210}
211
212/// Enrollment status returned in the `Enrolled` message.
213///
214/// # Wire forward-compatibility
215///
216/// `Other(String)` is a catch-all for status strings received from a newer
217/// controller that this build does not yet recognise. Serde deserialization
218/// is infallible: an unknown string becomes `Other(...)` rather than a parse
219/// error, allowing older agents to survive rolling upgrades without dropping
220/// the enclosing `Enrolled` message.
221#[non_exhaustive]
222#[derive(Debug, Clone, PartialEq, Eq)]
223#[cfg_attr(feature = "schema", derive(strum::EnumIter))]
224pub enum EnrollmentStatus {
225    Pending,
226    Approved,
227    /// An unknown status received from a newer peer.
228    ///
229    /// The inner string is the raw snake_case value as it appeared on the wire.
230    Other(String),
231}
232
233impl EnrollmentStatus {
234    /// Returns the string representation.
235    ///
236    /// For [`EnrollmentStatus::Other`], returns the inner string as-is.
237    pub fn as_str(&self) -> &str {
238        match self {
239            Self::Pending => "pending",
240            Self::Approved => "approved",
241            Self::Other(s) => s.as_str(),
242        }
243    }
244}
245
246impl fmt::Display for EnrollmentStatus {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.write_str(self.as_str())
249    }
250}
251
252impl From<String> for EnrollmentStatus {
253    /// Converts a snake_case string to an enrollment status.
254    ///
255    /// Unknown strings map to [`EnrollmentStatus::Other`] rather than failing.
256    fn from(s: String) -> Self {
257        match s.as_str() {
258            "pending" => Self::Pending,
259            "approved" => Self::Approved,
260            _ => {
261                tracing::debug!(status = s, "received unknown enrollment status from peer");
262                Self::Other(s)
263            }
264        }
265    }
266}
267
268impl Serialize for EnrollmentStatus {
269    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
270        serializer.serialize_str(self.as_str())
271    }
272}
273
274impl<'de> Deserialize<'de> for EnrollmentStatus {
275    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
276        String::deserialize(deserializer).map(EnrollmentStatus::from)
277    }
278}
279
280/// Machine-readable error code sent in `ErrorPayload`.
281///
282/// # Wire forward-compatibility
283///
284/// `Other(String)` is a catch-all for error codes received from a newer
285/// controller that this build does not yet recognise. Serde deserialization
286/// is infallible: an unknown string becomes `Other(...)` rather than a parse
287/// error, allowing older agents to survive rolling upgrades without dropping
288/// the enclosing `Error` message.
289#[non_exhaustive]
290#[derive(Debug, Clone, PartialEq, Eq)]
291#[cfg_attr(feature = "schema", derive(strum::EnumIter))]
292pub enum ErrorCode {
293    /// Malformed or unexpected message from the service.
294    BadRequest,
295    /// Enrollment attempt failed on the controller side.
296    EnrollmentFailed,
297    /// Service is not approved (pending or rejected).
298    NotApproved,
299    /// Service is not allowed to perform this action.
300    Forbidden,
301    /// Certificate signing or renewal error.
302    CertificateError,
303    /// Unrecoverable server-side error.
304    InternalError,
305    /// Message sequence number mismatch (replay protection).
306    SequenceError,
307    /// An unknown error code received from a newer peer.
308    ///
309    /// The inner string is the raw snake_case value as it appeared on the wire.
310    Other(String),
311}
312
313impl ErrorCode {
314    /// Returns the string representation.
315    ///
316    /// For [`ErrorCode::Other`], returns the inner string as-is.
317    pub fn as_str(&self) -> &str {
318        match self {
319            Self::BadRequest => "bad_request",
320            Self::EnrollmentFailed => "enrollment_failed",
321            Self::NotApproved => "not_approved",
322            Self::Forbidden => "forbidden",
323            Self::CertificateError => "certificate_error",
324            Self::InternalError => "internal_error",
325            Self::SequenceError => "sequence_error",
326            Self::Other(s) => s.as_str(),
327        }
328    }
329}
330
331impl fmt::Display for ErrorCode {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        f.write_str(self.as_str())
334    }
335}
336
337impl From<String> for ErrorCode {
338    /// Converts a snake_case string to an error code.
339    ///
340    /// Unknown strings map to [`ErrorCode::Other`] rather than failing.
341    fn from(s: String) -> Self {
342        match s.as_str() {
343            "bad_request" => Self::BadRequest,
344            "enrollment_failed" => Self::EnrollmentFailed,
345            "not_approved" => Self::NotApproved,
346            "forbidden" => Self::Forbidden,
347            "certificate_error" => Self::CertificateError,
348            "internal_error" => Self::InternalError,
349            "sequence_error" => Self::SequenceError,
350            _ => {
351                tracing::debug!(error_code = s, "received unknown error code from peer");
352                Self::Other(s)
353            }
354        }
355    }
356}
357
358impl Serialize for ErrorCode {
359    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
360        serializer.serialize_str(self.as_str())
361    }
362}
363
364impl<'de> Deserialize<'de> for ErrorCode {
365    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        String::deserialize(deserializer).map(ErrorCode::from)
367    }
368}
369
370/// Payload for error responses.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
373pub struct ErrorPayload {
374    pub code: ErrorCode,
375    pub message: String,
376}
377
378// ── JSON Schema impls for custom-serde enums ──────────────────────────────────
379//
380// `derive(schemars::JsonSchema)` would document the Rust variant identifiers
381// (PascalCase) rather than the wire strings — a silent semantic bug (spec §1).
382// These hand-written impls emit an OPEN string schema instead: `"type": "string"`
383// with known wire strings in the description and NO `"enum"` array, because the
384// `Other(String)` catch-all makes the value space open-ended.
385//
386// Known-value lists are derived via `strum::EnumIter` from the same `as_str()`
387// the `Serialize` impl uses — a hardcoded list here would drift silently.
388
389#[cfg(feature = "schema")]
390impl schemars::JsonSchema for Capability {
391    fn schema_name() -> std::borrow::Cow<'static, str> {
392        std::borrow::Cow::Borrowed("Capability")
393    }
394
395    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
396        use strum::IntoEnumIterator;
397        let known: Vec<String> = Capability::iter()
398            .filter(Capability::is_known)
399            .map(|c| c.as_str().to_string())
400            .collect();
401        schemars::json_schema!({
402            "type": "string",
403            "description": format!(
404                "Open wire string (unknown values are forward-compatible). Known values: {}.",
405                known.join(", ")
406            ),
407        })
408    }
409}
410
411#[cfg(feature = "schema")]
412impl schemars::JsonSchema for EnrollmentStatus {
413    fn schema_name() -> std::borrow::Cow<'static, str> {
414        std::borrow::Cow::Borrowed("EnrollmentStatus")
415    }
416
417    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
418        use strum::IntoEnumIterator;
419        let known: Vec<String> = EnrollmentStatus::iter()
420            .filter(|v| !matches!(v, Self::Other(_)))
421            .map(|v| v.as_str().to_string())
422            .collect();
423        schemars::json_schema!({
424            "type": "string",
425            "description": format!(
426                "Open wire string (unknown values are forward-compatible). Known values: {}.",
427                known.join(", ")
428            ),
429        })
430    }
431}
432
433#[cfg(feature = "schema")]
434impl schemars::JsonSchema for ErrorCode {
435    fn schema_name() -> std::borrow::Cow<'static, str> {
436        std::borrow::Cow::Borrowed("ErrorCode")
437    }
438
439    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
440        use strum::IntoEnumIterator;
441        let known: Vec<String> = ErrorCode::iter()
442            .filter(|v| !matches!(v, Self::Other(_)))
443            .map(|v| v.as_str().to_string())
444            .collect();
445        schemars::json_schema!({
446            "type": "string",
447            "description": format!(
448                "Open wire string (unknown values are forward-compatible). Known values: {}.",
449                known.join(", ")
450            ),
451        })
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    #[cfg(feature = "schema")]
458    mod schema_tests {
459        use super::super::*;
460
461        // Spec §6: manual impls must emit an OPEN string schema — never a closed
462        // enum list (the Other(String) catch-all makes the value space open).
463        fn assert_open_string_schema<T: schemars::JsonSchema>(known: &[&str]) {
464            let schema = schemars::schema_for!(T);
465            let value = serde_json::to_value(&schema).expect("schema to JSON");
466            assert_eq!(value["type"], "string");
467            assert!(
468                value.get("enum").is_none(),
469                "must be an open string schema, found closed enum list: {value}"
470            );
471            let desc = value["description"].as_str().expect("description present");
472            for k in known {
473                assert!(
474                    desc.contains(k),
475                    "known value {k} missing from description: {desc}"
476                );
477            }
478        }
479
480        #[test]
481        fn capability_schema_is_open_string_with_known_values() {
482            assert_open_string_schema::<Capability>(&[
483                "graceful_shutdown",
484                "workload_claims",
485                "ui_surfaces",
486            ]);
487        }
488
489        #[test]
490        fn enrollment_status_schema_is_open_string_with_known_values() {
491            assert_open_string_schema::<EnrollmentStatus>(&["pending", "approved"]);
492        }
493
494        #[test]
495        fn error_code_schema_is_open_string_with_known_values() {
496            assert_open_string_schema::<ErrorCode>(&["bad_request", "forbidden", "internal_error"]);
497        }
498    }
499}