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#[non_exhaustive]
18pub enum Capability {
19 /// Service participates in the graceful-shutdown protocol: sends
20 /// `Disconnecting` before clean exit and honours
21 /// `shutdown_timeout_seconds` from `ServiceSettings`.
22 ///
23 /// Wire string: `graceful_shutdown`.
24 GracefulShutdown,
25 /// Service tracks software update state and host connectivity.
26 ///
27 /// The controller uses this capability to route software-state broadcasts
28 /// and connectivity updates. Also gates MQTT-specific lease coordination
29 /// for MQTT bridge services.
30 ///
31 /// Wire string: `update_tracking`.
32 UpdateTracking,
33 /// Service supports `DiscoverSoftware` → `DiscoveryResults` flow.
34 ///
35 /// The controller gates autodiscovery requests on this capability.
36 ///
37 /// Wire string: `software_discovery`.
38 SoftwareDiscovery,
39 /// Service manages remote hosts over SSH, rather than running locally.
40 ///
41 /// Identifies an SSH-backed agent. Combined with `SoftwareDiscovery`,
42 /// uniquely identifies an SSH agent (vs. a local agent).
43 ///
44 /// Wire string: `ssh_remote`.
45 SshRemote,
46 /// Service supports pre-/post-update lifecycle hook plugins
47 /// (`PluginAssignment` in `ExecuteUpdatePayload`). The controller omits
48 /// hook plugins when absent.
49 ///
50 /// Wire string: `update_hooks`.
51 UpdateHooks,
52 /// Marker: service is an external task scheduler.
53 ///
54 /// Identifies a service that runs scheduled tasks (version checks, cert
55 /// checks, auth cleanup, etc.) externally. The controller uses this to
56 /// detect scheduler presence and disable the embedded scheduler.
57 ///
58 /// Wire string: `scheduler`.
59 Scheduler,
60 /// Service requires direct database access. The controller will include
61 /// `db_url` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload).
62 ///
63 /// Wire string: `database_access`.
64 DatabaseAccess,
65 /// Service requires NATS access. The controller will include `nats_url`
66 /// in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if NATS is configured).
67 ///
68 /// Wire string: `nats_access`.
69 NatsAccess,
70 /// Service requires the master encryption key. The controller will include
71 /// `master_key_hex` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if encryption is enabled).
72 ///
73 /// Wire string: `master_key_access`.
74 MasterKeyAccess,
75 /// Service can request CA certificate rotation via [`RequestCaRotationPayload`](super::payloads::RequestCaRotationPayload).
76 /// The controller will accept `RequestCaRotation` messages from services
77 /// with this capability (via NATS or local delivery).
78 ///
79 /// Wire string: `ca_management`.
80 CaManagement,
81 /// Service is a global infrastructure service, not bound to any tenant.
82 ///
83 /// When present in an `EnrollPayload`, the controller routes enrollment to
84 /// the `system_services` table instead of the per-tenant `services` table.
85 ///
86 /// **Credential guard**: any service requesting `DatabaseAccess`,
87 /// `NatsAccess`, `MasterKeyAccess`, or `CaManagement` without also
88 /// advertising `SystemService` will be rejected at enrollment with a 403
89 /// error. This prevents regular tenant agents from claiming infrastructure
90 /// credentials.
91 ///
92 /// Wire string: `system_service`.
93 SystemService,
94 /// Service supports UI surfaces: it will send `SurfaceRegistration` after
95 /// connection and respond to surface action messages.
96 ///
97 /// Wire string: `ui_surfaces`.
98 UiSurfaces,
99 /// Service supports interactive update sessions: PTY allocation, stdin
100 /// forwarding, and signal delivery during update execution.
101 ///
102 /// When present, the controller may set `interactive: true` on
103 /// `ExecuteUpdatePayload` and send `UpdateStdinData` messages to this
104 /// service. The service allocates a PTY for the update process and keeps
105 /// stdin open for forwarding.
106 ///
107 /// Wire string: `interactive_updates`.
108 InteractiveUpdates,
109 /// Service supports the reset-data protocol: truncates local data stores
110 /// when the controller broadcasts a data reset.
111 ///
112 /// Wire string: `reset_data`.
113 ResetData,
114 /// Service participates in the workload claim protocol for exclusive
115 /// config-key ownership.
116 ///
117 /// Services with this capability send `WorkloadClaim` after receiving
118 /// `ServiceConfigDelivery` to request exclusive ownership of config keys.
119 /// The controller responds with `WorkloadClaimResult` and routes
120 /// tenant-scoped messages only to services that hold granted claims.
121 ///
122 /// Wire string: `workload_claims`.
123 WorkloadClaims,
124 /// Unknown capability from a newer peer; never participates in intersection.
125 ///
126 /// Provides forward compatibility: a newer peer may advertise capabilities
127 /// that an older build does not yet recognise. These are preserved on receipt
128 /// but never emitted by the current codebase.
129 Other(String),
130}
131
132impl Capability {
133 /// Returns the snake_case wire string for this capability.
134 pub fn as_str(&self) -> &str {
135 match self {
136 Self::SoftwareDiscovery => "software_discovery",
137 Self::UpdateHooks => "update_hooks",
138 Self::GracefulShutdown => "graceful_shutdown",
139 Self::UpdateTracking => "update_tracking",
140 Self::SshRemote => "ssh_remote",
141 Self::Scheduler => "scheduler",
142 Self::DatabaseAccess => "database_access",
143 Self::NatsAccess => "nats_access",
144 Self::MasterKeyAccess => "master_key_access",
145 Self::CaManagement => "ca_management",
146 Self::SystemService => "system_service",
147 Self::UiSurfaces => "ui_surfaces",
148 Self::InteractiveUpdates => "interactive_updates",
149 Self::ResetData => "reset_data",
150 Self::WorkloadClaims => "workload_claims",
151 Self::Other(s) => s.as_str(),
152 }
153 }
154
155 /// Returns `true` for typed variants; `Other` returns `false`.
156 ///
157 /// Only typed variants participate in capability intersection. `Other` values
158 /// are forwarded-compatibility markers and must not gate behaviour.
159 pub fn is_known(&self) -> bool {
160 !matches!(self, Self::Other(_))
161 }
162}
163
164impl fmt::Display for Capability {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 f.write_str(self.as_str())
167 }
168}
169
170impl FromStr for Capability {
171 type Err = std::convert::Infallible;
172
173 fn from_str(s: &str) -> Result<Self, Self::Err> {
174 Ok(match s {
175 "software_discovery" => Self::SoftwareDiscovery,
176 "update_hooks" => Self::UpdateHooks,
177 "graceful_shutdown" => Self::GracefulShutdown,
178 "update_tracking" => Self::UpdateTracking,
179 "ssh_remote" => Self::SshRemote,
180 "scheduler" => Self::Scheduler,
181 "database_access" => Self::DatabaseAccess,
182 "nats_access" => Self::NatsAccess,
183 "master_key_access" => Self::MasterKeyAccess,
184 "ca_management" => Self::CaManagement,
185 "system_service" => Self::SystemService,
186 "ui_surfaces" => Self::UiSurfaces,
187 "interactive_updates" => Self::InteractiveUpdates,
188 "reset_data" => Self::ResetData,
189 "workload_claims" => Self::WorkloadClaims,
190 other => {
191 tracing::debug!(capability = other, "received unknown capability from peer");
192 Self::Other(other.to_string())
193 }
194 })
195 }
196}
197
198impl Serialize for Capability {
199 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
200 serializer.serialize_str(self.as_str())
201 }
202}
203
204impl<'de> Deserialize<'de> for Capability {
205 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
206 let s = String::deserialize(deserializer)?;
207 Ok(s.parse().unwrap_or(Capability::Other(s)))
208 }
209}
210
211/// Enrollment status returned in the `Enrolled` message.
212///
213/// # Wire forward-compatibility
214///
215/// `Other(String)` is a catch-all for status strings received from a newer
216/// controller that this build does not yet recognise. Serde deserialization
217/// is infallible: an unknown string becomes `Other(...)` rather than a parse
218/// error, allowing older agents to survive rolling upgrades without dropping
219/// the enclosing `Enrolled` message.
220#[non_exhaustive]
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum EnrollmentStatus {
223 Pending,
224 Approved,
225 /// An unknown status received from a newer peer.
226 ///
227 /// The inner string is the raw snake_case value as it appeared on the wire.
228 Other(String),
229}
230
231impl EnrollmentStatus {
232 /// Returns the string representation.
233 ///
234 /// For [`EnrollmentStatus::Other`], returns the inner string as-is.
235 pub fn as_str(&self) -> &str {
236 match self {
237 Self::Pending => "pending",
238 Self::Approved => "approved",
239 Self::Other(s) => s.as_str(),
240 }
241 }
242}
243
244impl fmt::Display for EnrollmentStatus {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.write_str(self.as_str())
247 }
248}
249
250impl From<String> for EnrollmentStatus {
251 /// Converts a snake_case string to an enrollment status.
252 ///
253 /// Unknown strings map to [`EnrollmentStatus::Other`] rather than failing.
254 fn from(s: String) -> Self {
255 match s.as_str() {
256 "pending" => Self::Pending,
257 "approved" => Self::Approved,
258 _ => {
259 tracing::debug!(status = s, "received unknown enrollment status from peer");
260 Self::Other(s)
261 }
262 }
263 }
264}
265
266impl Serialize for EnrollmentStatus {
267 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268 serializer.serialize_str(self.as_str())
269 }
270}
271
272impl<'de> Deserialize<'de> for EnrollmentStatus {
273 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274 String::deserialize(deserializer).map(EnrollmentStatus::from)
275 }
276}
277
278/// Machine-readable error code sent in `ErrorPayload`.
279///
280/// # Wire forward-compatibility
281///
282/// `Other(String)` is a catch-all for error codes received from a newer
283/// controller that this build does not yet recognise. Serde deserialization
284/// is infallible: an unknown string becomes `Other(...)` rather than a parse
285/// error, allowing older agents to survive rolling upgrades without dropping
286/// the enclosing `Error` message.
287#[non_exhaustive]
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum ErrorCode {
290 /// Malformed or unexpected message from the service.
291 BadRequest,
292 /// Enrollment attempt failed on the controller side.
293 EnrollmentFailed,
294 /// Service is not approved (pending or rejected).
295 NotApproved,
296 /// Service is not allowed to perform this action.
297 Forbidden,
298 /// Certificate signing or renewal error.
299 CertificateError,
300 /// Unrecoverable server-side error.
301 InternalError,
302 /// Message sequence number mismatch (replay protection).
303 SequenceError,
304 /// An unknown error code received from a newer peer.
305 ///
306 /// The inner string is the raw snake_case value as it appeared on the wire.
307 Other(String),
308}
309
310impl ErrorCode {
311 /// Returns the string representation.
312 ///
313 /// For [`ErrorCode::Other`], returns the inner string as-is.
314 pub fn as_str(&self) -> &str {
315 match self {
316 Self::BadRequest => "bad_request",
317 Self::EnrollmentFailed => "enrollment_failed",
318 Self::NotApproved => "not_approved",
319 Self::Forbidden => "forbidden",
320 Self::CertificateError => "certificate_error",
321 Self::InternalError => "internal_error",
322 Self::SequenceError => "sequence_error",
323 Self::Other(s) => s.as_str(),
324 }
325 }
326}
327
328impl fmt::Display for ErrorCode {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 f.write_str(self.as_str())
331 }
332}
333
334impl From<String> for ErrorCode {
335 /// Converts a snake_case string to an error code.
336 ///
337 /// Unknown strings map to [`ErrorCode::Other`] rather than failing.
338 fn from(s: String) -> Self {
339 match s.as_str() {
340 "bad_request" => Self::BadRequest,
341 "enrollment_failed" => Self::EnrollmentFailed,
342 "not_approved" => Self::NotApproved,
343 "forbidden" => Self::Forbidden,
344 "certificate_error" => Self::CertificateError,
345 "internal_error" => Self::InternalError,
346 "sequence_error" => Self::SequenceError,
347 _ => {
348 tracing::debug!(error_code = s, "received unknown error code from peer");
349 Self::Other(s)
350 }
351 }
352 }
353}
354
355impl Serialize for ErrorCode {
356 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
357 serializer.serialize_str(self.as_str())
358 }
359}
360
361impl<'de> Deserialize<'de> for ErrorCode {
362 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
363 String::deserialize(deserializer).map(ErrorCode::from)
364 }
365}
366
367/// Payload for error responses.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct ErrorPayload {
370 pub code: ErrorCode,
371 pub message: String,
372}