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}