uptrakit_wire/payloads.rs
1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use time::UtcDateTime;
5use uuid::Uuid;
6
7use super::capabilities::{Capability, EnrollmentStatus};
8use super::shared_types::{DisconnectReason, UpdateFinalStatus};
9use crate::serde_helpers::{duration_seconds, option_duration_seconds, utc_datetime_millis};
10use uptrakit_shared_types::{
11 DiscoveredSoftware, OutputStreamType, PluginTypeId, ReleaseInfo, SecretString, UpdateCategory,
12};
13
14/// Payload for ping messages.
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct PingPayload {
18 /// Timestamp when the service sent the ping.
19 pub service_ts: super::shared_types::Timestamp,
20}
21
22impl PingPayload {
23 /// Creates a new `PingPayload` with the given service timestamp.
24 pub fn new(service_ts: super::shared_types::Timestamp) -> Self {
25 Self { service_ts }
26 }
27}
28
29/// Payload for pong messages.
30#[non_exhaustive]
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PongPayload {
33 /// Original timestamp from the service's ping.
34 pub service_ts: super::shared_types::Timestamp,
35 /// Timestamp when the controller processed the ping.
36 pub controller_ts: super::shared_types::Timestamp,
37}
38
39impl PongPayload {
40 /// Creates a new `PongPayload` with the given service and controller timestamps.
41 pub fn new(
42 service_ts: super::shared_types::Timestamp,
43 controller_ts: super::shared_types::Timestamp,
44 ) -> Self {
45 Self {
46 service_ts,
47 controller_ts,
48 }
49 }
50}
51
52/// Information about the host machine running the agent.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct HostInfo {
55 /// Persistent machine identifier (e.g. `/etc/machine-id` on Linux, `IOPlatformUUID` on macOS).
56 pub machine_id: String,
57 /// Operating system type (e.g. "linux", "macos").
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub os_type: Option<String>,
60 /// Operating system version (e.g. "Ubuntu 24.04 LTS").
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub os_version: Option<String>,
63 /// CPU architecture (e.g. "x86_64", "aarch64").
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub architecture: Option<String>,
66 /// Hostname reported by the agent/host machine.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub hostname: Option<String>,
69 /// Network address of the host (SSH target address for SSH agent hosts).
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub ip_address: Option<String>,
72 /// Agent-local UUID assigned to this host at bootstrap time.
73 ///
74 /// When present, the controller uses this as `hosts.id` when creating a
75 /// new row, ensuring agent and controller share the same UUID. This is
76 /// required for plugin FK operations (e.g. Proxmox host mapping) that
77 /// reference `hosts.id` before the controller has generated its own UUID.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub agent_host_id: Option<Uuid>,
80 /// Agent-probed host features (e.g. `["posix_shell", "privilege_escalation", "systemd"]`).
81 ///
82 /// `None` for legacy agents that predate feature reporting. Uses `Vec<String>`
83 /// (not `BTreeSet<HostFeature>`) on the wire for forward-compatibility: if a
84 /// newer agent reports a feature the controller doesn't know, it is stored
85 /// losslessly and ignored by `HostCapabilities` parsing.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub features: Option<Vec<String>>,
88}
89
90/// Payload for service enrollment request.
91///
92/// Used by both agents and MQTT services. Host information is reported
93/// separately via [`ReportHostsPayload`] after authentication.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct EnrollPayload {
96 pub hostname: String,
97 pub friendly_name: String,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub enrollment_token: Option<SecretString>,
100 /// Capabilities this service supports.
101 ///
102 /// The controller persists these in the `services.capabilities` column and
103 /// derives behavioral defaults from the resulting [`ServiceProfile`](crate::ServiceProfile).
104 pub capabilities: BTreeSet<Capability>,
105 /// The binary/crate name of the enrolling service (e.g., `"uptrakit-agent-ssh"`).
106 ///
107 /// Derived from `env!("CARGO_PKG_NAME")` at compile time. Used for UI
108 /// display, extension conflict detection, and distinguishing service binaries.
109 pub service_app_name: String,
110}
111
112/// Payload for requesting a client certificate after approval.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct RequestCertificatePayload {
115 /// PEM-encoded Certificate Signing Request.
116 pub csr_pem: String,
117}
118
119/// Payload for requesting certificate renewal (mTLS-authenticated services).
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct RenewCertificatePayload {
122 /// PEM-encoded Certificate Signing Request with CN=service_id.
123 pub csr_pem: String,
124}
125
126/// Payload for reporting host information (sent by authenticated agents on connect).
127///
128/// Supports multiple hosts per message, enabling a single service instance
129/// (e.g. a future SSH-backed agent) to manage several remote hosts.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ReportHostsPayload {
132 /// One or more host machines managed by this service.
133 pub hosts: Vec<HostInfo>,
134 /// Agent binary version (e.g., "0.0.1").
135 pub agent_version: String,
136 /// Capabilities advertised by this service.
137 ///
138 /// The controller computes the agreed set as the intersection of this set
139 /// with its own capabilities, considering only typed (known) variants.
140 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
141 pub capabilities: BTreeSet<Capability>,
142}
143
144/// Payload for enrollment confirmation.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct EnrolledPayload {
147 pub service_id: Uuid,
148 pub enrollment_secret: SecretString,
149 pub status: EnrollmentStatus,
150}
151
152/// Payload for approval notification.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct ApprovedPayload {
155 pub service_id: Uuid,
156}
157
158/// Payload for rejection notification.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct RejectedPayload {
161 pub service_id: Uuid,
162}
163
164/// Payload for issued certificate.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct CertificatePayload {
167 pub cert_pem: String,
168 /// Certificate "not valid after" timestamp.
169 #[serde(with = "utc_datetime_millis")]
170 pub not_after: UtcDateTime,
171}
172
173/// Payload for service runtime settings pushed by the controller.
174///
175/// Used for both agents and MQTT services. `shutdown_timeout` is
176/// present for agents and `None` for MQTT services.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ServiceSettingsPayload {
179 pub renewal_window_hours: u16,
180 #[serde(default)]
181 pub ca_bundle_hash: String,
182 /// Capabilities advertised by the controller.
183 ///
184 /// The service computes the agreed set as the intersection of this set
185 /// with its own capabilities, considering only typed (known) variants.
186 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
187 pub capabilities: BTreeSet<Capability>,
188 /// Per-page item-count limits for paginated service-to-controller reports.
189 ///
190 /// Services must honor these limits when splitting large `report_hosts`,
191 /// `discovery_results`, `version_check_results`, and
192 /// `batch_update_result` payloads across pages.
193 #[serde(default, skip_serializing_if = "ReportPageLimits::is_default")]
194 pub report_page_limits: ReportPageLimits,
195 /// Maximum time to wait for in-flight operations during shutdown.
196 /// Present for agents, absent for MQTT services.
197 ///
198 /// Wire field name: `shutdown_timeout_seconds` (kept for backward compatibility).
199 #[serde(
200 default,
201 skip_serializing_if = "Option::is_none",
202 with = "option_duration_seconds",
203 rename = "shutdown_timeout_seconds"
204 )]
205 pub shutdown_timeout: Option<std::time::Duration>,
206 /// How often the service should send ping messages.
207 /// Controller-managed; derived from per-service DB override or service-type default.
208 #[serde(with = "duration_seconds")]
209 pub ping_interval: std::time::Duration,
210 /// Tenant UUID that this service belongs to.
211 ///
212 /// `None` for system services (MQTT, scheduler) which are not
213 /// tenant-scoped. Present for tenant-scoped agents so they can
214 /// include the tenant identity in external provisioning operations
215 /// (e.g. PVE API credential naming).
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub tenant_id: Option<Uuid>,
218}
219
220/// Per-page item-count limits for paginated report payloads.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ReportPageLimits {
223 /// Maximum `hosts` items per `report_hosts` page.
224 pub report_hosts: u32,
225 /// Maximum `results` items per `version_check_results` page.
226 pub version_check_results: u32,
227 /// Maximum `results` items per `discovery_results` page.
228 pub discovery_results: u32,
229 /// Maximum `results` items per `batch_update_result` page.
230 pub batch_update_results: u32,
231}
232
233impl ReportPageLimits {
234 /// Returns `true` when all fields match the default wire limits.
235 pub fn is_default(&self) -> bool {
236 self == &Self::default()
237 }
238}
239
240impl Default for ReportPageLimits {
241 fn default() -> Self {
242 Self {
243 report_hosts: crate::limits::MAX_REPORT_HOSTS as u32,
244 version_check_results: crate::limits::MAX_VERSION_CHECK_RESULTS as u32,
245 discovery_results: crate::limits::MAX_DISCOVERY_PLUGIN_RESULTS as u32,
246 batch_update_results: crate::limits::MAX_BATCH_UPDATE_RESULTS as u32,
247 }
248 }
249}
250
251/// Payload for CA bundle update notification.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CaBundleUpdatedPayload {
254 pub ca_bundle_pem: String,
255}
256
257/// Payload for requesting immediate certificate renewal from services.
258///
259/// Sent by the controller after CA rotation or backend URL change to prompt
260/// all connected services to renew their certificates with the new CA.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct RequestCertRenewalPayload {
263 /// Human-readable reason for the renewal request.
264 pub reason: String,
265}
266
267/// Payload for server restarting notification.
268///
269/// Sent by the controller during graceful shutdown to notify connected services
270/// that the server is restarting. Services should expect the connection to close
271/// and reconnect automatically.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct ServerRestartingPayload {
274 /// Human-readable reason for the restart.
275 pub reason: String,
276}
277
278/// Payload for requesting version checks from the agent.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct CheckVersionsPayload {
281 /// The machine_id of the host to check versions on.
282 ///
283 /// For the regular agent (one service = one host), the agent validates that
284 /// this matches its own machine_id as a defensive sanity check.
285 /// For the SSH agent (one service = N remote hosts), the agent uses this
286 /// field to look up the correct SSH credentials and route the operation to
287 /// the right remote host.
288 pub host_machine_id: String,
289 /// List of software items to check.
290 pub assignments: Vec<VersionCheckAssignment>,
291}
292
293/// A plugin assignment for a specific role in a version check or update.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct PluginAssignment {
296 /// The plugin type (e.g. github_releases, apt, homebrew).
297 pub plugin_type: PluginTypeId,
298 /// Package identifier for this role's plugin.
299 pub package_identifier: String,
300 /// Merged plugin config (base + override).
301 pub config: serde_json::Value,
302}
303
304/// A single software item to check for installed version and/or latest version.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct VersionCheckAssignment {
307 /// Software item ID.
308 pub software_item_id: Uuid,
309 /// Human-readable name for logging.
310 pub name: String,
311 /// Plugin for the detect_version role.
312 /// None if no detect_version plugin is configured for this host-software pair.
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub detect_version: Option<PluginAssignment>,
315 /// Plugin for the fetch_releases role — only included for agent-side plugins
316 /// (i.e., plugins without ControllerSideFetchReleases or with execution_site = agent).
317 /// Controller-side fetch_releases is handled by the scheduler, not sent to the agent.
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub fetch_releases: Option<PluginAssignment>,
320 /// Host software item ID for routing results to the host_software_items table.
321 /// When set, this assignment is for a host-managed software item rather than
322 /// a targeted software item.
323 #[serde(
324 default,
325 skip_serializing_if = "Option::is_none",
326 alias = "host_package_id"
327 )]
328 pub host_software_item_id: Option<Uuid>,
329}
330
331/// Payload for version check results from the agent.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct VersionCheckResultsPayload {
334 /// Results for each checked software item.
335 pub results: Vec<VersionCheckResult>,
336}
337
338/// Result of a single version check.
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct VersionCheckResult {
341 /// Software item ID.
342 pub software_item_id: Uuid,
343 /// Detected installed version, if any.
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub installed_version: Option<String>,
346 /// Latest available version from the package index, if resolved locally
347 /// by the agent (e.g., Homebrew). Absent for plugins whose latest
348 /// version is resolved on the controller side.
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub latest_version: Option<String>,
351 /// Error message if detection failed.
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub error: Option<String>,
354 /// Classification of the available update (e.g. security, bugfix).
355 /// Defaults to `Unknown` when the plugin cannot classify the update.
356 #[serde(default)]
357 pub update_category: UpdateCategory,
358 /// Host software item ID for routing results to the host_software_items table.
359 /// Mirrors the value from the corresponding [`VersionCheckAssignment`].
360 #[serde(
361 default,
362 skip_serializing_if = "Option::is_none",
363 alias = "host_package_id"
364 )]
365 pub host_software_item_id: Option<Uuid>,
366 /// Human-readable installed version for display when `installed_version`
367 /// is opaque (e.g. a Docker SHA256 digest → the image publish date).
368 /// Set by the agent from `BatchDetectResult.display_version`.
369 /// `None` when the plugin does not provide a display version.
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub installed_display_version: Option<String>,
372}
373
374// --- Update execution messages ---
375
376/// Controller -> Agent: Trigger an update.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct ExecuteUpdatePayload {
379 /// The machine_id of the host to run the update on.
380 ///
381 /// For the regular agent (one service = one host), the agent validates that
382 /// this matches its own machine_id as a defensive sanity check.
383 /// For the SSH agent (one service = N remote hosts), the agent uses this
384 /// field to look up the correct SSH credentials and route the operation to
385 /// the right remote host.
386 pub host_machine_id: String,
387 pub update_history_id: Uuid,
388 pub software_item_id: Uuid,
389 pub software_item_name: String,
390 pub to_version: String,
391 /// Plugin for the detect_version role (for before/after installed-version detection).
392 /// Absent when no detect_version plugin is configured for this assignment.
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub detect_version_plugin: Option<PluginAssignment>,
395 /// Plugin for the execute_update role.
396 pub execute_update_plugin: PluginAssignment,
397 /// Pre-update hook plugins to execute before the update, ordered by priority.
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub pre_update_hook_plugins: Vec<PluginAssignment>,
400 /// Post-update hook plugins to execute after the update, ordered by priority.
401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
402 pub post_update_hook_plugins: Vec<PluginAssignment>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub release_info: Option<ReleaseInfo>,
405 /// Timeout for the update execution.
406 ///
407 /// Wire field name: `timeout_seconds` (kept for backward compatibility).
408 #[serde(
409 with = "duration_seconds",
410 rename = "timeout_seconds",
411 default = "super::shared_types::default_update_timeout"
412 )]
413 pub timeout: std::time::Duration,
414 /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
415 ///
416 /// Requires the agent to advertise the `InteractiveUpdates` capability.
417 /// Defaults to `false` for backward compatibility with older agents.
418 #[serde(default)]
419 pub interactive: bool,
420}
421
422/// Agent -> Controller: Update is starting.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct UpdateStartedPayload {
425 pub update_history_id: Uuid,
426 #[serde(skip_serializing_if = "Option::is_none")]
427 pub from_version: Option<String>,
428 /// Whether a PTY was actually allocated for this update.
429 /// `false` for non-interactive updates or when PTY allocation failed.
430 /// Old agents that do not send this field will deserialize as `false`.
431 #[serde(default)]
432 pub interactive: bool,
433}
434
435/// Agent -> Controller: Streaming output line.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct UpdateOutputPayload {
438 pub update_history_id: Uuid,
439 pub output: String,
440 #[serde(default)]
441 pub stream: OutputStreamType,
442}
443
444/// Agent -> Controller: Final result of update execution.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446pub struct UpdateResultPayload {
447 pub update_history_id: Uuid,
448 pub status: UpdateFinalStatus,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub from_version: Option<String>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub to_version: Option<String>,
453 pub output: String,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub error: Option<String>,
456}
457
458// --- Batch update messages ---
459
460/// Controller → Agent: execute a batch update of software items.
461///
462/// Groups multiple items under a single plugin type so the agent can
463/// run a single bulk command (e.g., `apt-get upgrade`, `brew upgrade`).
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct ExecuteBatchUpdatePayload {
466 /// The machine_id of the host to run the update on.
467 pub host_machine_id: String,
468 /// Unique identifier for this batch operation.
469 pub batch_id: Uuid,
470 /// Plugin type for all items in this batch.
471 pub plugin_type: PluginTypeId,
472 /// Merged plugin configuration.
473 pub plugin_config: serde_json::Value,
474 /// Individual items to update.
475 pub updates: Vec<BatchUpdateItem>,
476 /// Pre-update hook plugins to execute before the batch, ordered by priority.
477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
478 pub pre_update_hook_plugins: Vec<PluginAssignment>,
479 /// Post-update hook plugins to execute after the batch, ordered by priority.
480 #[serde(default, skip_serializing_if = "Vec::is_empty")]
481 pub post_update_hook_plugins: Vec<PluginAssignment>,
482 /// Timeout for the entire batch operation.
483 ///
484 /// Wire field name: `timeout_seconds` (kept for backward compatibility).
485 #[serde(
486 with = "duration_seconds",
487 rename = "timeout_seconds",
488 default = "super::shared_types::default_update_timeout"
489 )]
490 pub timeout: std::time::Duration,
491 /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
492 ///
493 /// Requires the agent to advertise the `InteractiveUpdates` capability.
494 /// Defaults to `false` for backward compatibility with older agents.
495 #[serde(default)]
496 pub interactive: bool,
497}
498
499/// A single software item within a batch update request.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct BatchUpdateItem {
502 /// Host software item entity ID.
503 #[serde(alias = "host_package_id")]
504 pub host_software_item_id: Uuid,
505 /// Update history record ID (pre-created by the controller).
506 pub update_history_id: Uuid,
507 /// Plugin-specific package identifier (e.g., APT package name).
508 pub package_identifier: String,
509 /// Target version to install.
510 pub to_version: String,
511 /// Optional release metadata from the upstream source.
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub release_info: Option<ReleaseInfo>,
514}
515
516/// Agent → Controller: result of a batch update.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct BatchUpdateResultPayload {
519 /// Batch ID matching the request.
520 pub batch_id: Uuid,
521 /// Per-item results.
522 pub results: Vec<BatchUpdateItemResult>,
523}
524
525/// Result of updating a single item within a batch operation.
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct BatchUpdateItemResult {
528 /// Host software item entity ID.
529 #[serde(alias = "host_package_id")]
530 pub host_software_item_id: Uuid,
531 /// Update history record ID.
532 pub update_history_id: Uuid,
533 /// Final status of this item's update.
534 pub status: UpdateFinalStatus,
535 /// Accumulated output from the update.
536 pub output: String,
537 /// Detected installed version after the update (if detection succeeded).
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub installed_version: Option<String>,
540 /// Error message if the update failed.
541 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub error: Option<String>,
543}
544
545// --- Remote update freeze ---
546
547/// Controller → Agent: enable or disable the update freeze.
548///
549/// When `enabled` is `true`, the agent creates its freeze file, which blocks
550/// `ExecuteUpdate` and `ExecuteBatchUpdate` messages until the file
551/// is removed (either via a subsequent `SetUpdateFreeze { enabled: false }`
552/// message, or manually on the host via `rm <freeze-file>`).
553///
554/// This message is safe for NATS publication — it contains no credentials.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct SetUpdateFreezePayload {
557 /// Whether to enable (`true`) or disable (`false`) the freeze.
558 pub enabled: bool,
559 /// Optional human-readable reason for the freeze (audit trail).
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub reason: Option<String>,
562}
563
564// --- Graceful shutdown messages ---
565
566/// Service -> Controller: Notification before disconnecting.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct DisconnectingPayload {
569 pub reason: DisconnectReason,
570}
571
572impl DisconnectingPayload {
573 /// Create a `DisconnectingPayload` with the given reason.
574 pub fn new(reason: DisconnectReason) -> Self {
575 Self { reason }
576 }
577}
578
579// =============================================================================
580// Capability Management Payloads
581// =============================================================================
582
583/// Payload sent by every service on connect to declare its capabilities.
584///
585/// Sent from `on_connected` before any other messages. The controller uses
586/// this as the authoritative source for capability detection on the current
587/// session and persists the capability set to the DB.
588#[non_exhaustive]
589#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
590pub struct RegisterPayload {
591 /// Capabilities declared by this service instance.
592 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
593 pub capabilities: BTreeSet<Capability>,
594 /// Runtime instance identity for restart-vs-reconnect detection.
595 ///
596 /// Optional for mixed-version compatibility: legacy services omit this
597 /// field and are treated as service-scoped (not instance-scoped).
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub runtime_instance_id: Option<Uuid>,
600}
601
602impl RegisterPayload {
603 /// Create a new [`RegisterPayload`] with the given capability set.
604 ///
605 /// # Example
606 ///
607 /// ```
608 /// use std::collections::BTreeSet;
609 /// use uptrakit_wire::{Capability, RegisterPayload};
610 ///
611 /// let payload = RegisterPayload::new([Capability::SoftwareDiscovery, Capability::UpdateHooks]);
612 /// assert!(payload.capabilities.contains(&Capability::SoftwareDiscovery));
613 /// ```
614 pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self {
615 Self {
616 capabilities: capabilities.into_iter().collect(),
617 runtime_instance_id: None,
618 }
619 }
620
621 /// Set a runtime instance id on this register payload.
622 #[must_use]
623 pub fn with_runtime_instance_id(mut self, runtime_instance_id: Uuid) -> Self {
624 self.runtime_instance_id = Some(runtime_instance_id);
625 self
626 }
627}
628
629// =============================================================================
630// Service Config Store Payloads
631// =============================================================================
632
633/// A single stored service config entry, delivered to the service.
634///
635/// Sensitive values are already decrypted by the controller before delivery.
636#[non_exhaustive]
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct ServiceConfigEntry {
639 /// Tenant this entry belongs to, or `None` for global entries.
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub tenant_id: Option<Uuid>,
642 /// Entry key (e.g. `"clients.{uuid}"`).
643 pub key: String,
644 /// Entry value (plaintext JSON; controller decrypts before delivery).
645 pub value: serde_json::Value,
646}
647
648impl ServiceConfigEntry {
649 /// Create a new `ServiceConfigEntry`.
650 pub fn new(tenant_id: Option<Uuid>, key: String, value: serde_json::Value) -> Self {
651 Self {
652 tenant_id,
653 key,
654 value,
655 }
656 }
657}
658
659/// Identifies a service config entry by scope and key (used in delete notifications).
660#[non_exhaustive]
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662pub struct ServiceConfigKey {
663 /// Tenant this entry belongs to, or `None` for global entries.
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub tenant_id: Option<Uuid>,
666 /// Entry key.
667 pub key: String,
668}
669
670impl ServiceConfigKey {
671 /// Create a new `ServiceConfigKey`.
672 pub fn new(tenant_id: Option<Uuid>, key: String) -> Self {
673 Self { tenant_id, key }
674 }
675}
676
677/// Service → Controller: write or update a config entry.
678///
679/// The controller upserts the entry in `tenant_service_config` (when
680/// `tenant_id` is set) or `global_service_config` (when `None`), encrypts
681/// the value if `sensitive` is `true`, ACKs the operation, and broadcasts
682/// `ServiceConfigUpdated` to all other connected instances of the same
683/// `service_app_name`.
684#[non_exhaustive]
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686pub struct StoreServiceConfigPayload {
687 /// Correlation ID for the `ServiceConfigAck` response.
688 pub request_id: String,
689 /// Tenant scope. `None` = global scope.
690 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub tenant_id: Option<Uuid>,
692 /// Config key (e.g. `"clients.{uuid}"`).
693 pub key: String,
694 /// Config value (plaintext JSON; controller encrypts at rest if `sensitive`).
695 pub value: serde_json::Value,
696 /// When `true`, the controller stores the value using `EncryptedString`.
697 #[serde(default)]
698 pub sensitive: bool,
699}
700
701impl StoreServiceConfigPayload {
702 /// Create a new `StoreServiceConfigPayload`.
703 pub fn new(
704 request_id: String,
705 tenant_id: Option<Uuid>,
706 key: String,
707 value: serde_json::Value,
708 sensitive: bool,
709 ) -> Self {
710 Self {
711 request_id,
712 tenant_id,
713 key,
714 value,
715 sensitive,
716 }
717 }
718}
719
720/// Service → Controller: delete a config entry.
721///
722/// The controller deletes the entry, ACKs, and broadcasts `ServiceConfigUpdated`
723/// to all other connected instances of the same `service_app_name`.
724#[non_exhaustive]
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct DeleteServiceConfigPayload {
727 /// Correlation ID for the `ServiceConfigAck` response.
728 pub request_id: String,
729 /// Tenant scope. `None` = global scope.
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub tenant_id: Option<Uuid>,
732 /// Config key to delete.
733 pub key: String,
734}
735
736impl DeleteServiceConfigPayload {
737 /// Create a new `DeleteServiceConfigPayload`.
738 pub fn new(request_id: String, tenant_id: Option<Uuid>, key: String) -> Self {
739 Self {
740 request_id,
741 tenant_id,
742 key,
743 }
744 }
745}
746
747/// Controller → Service: acknowledgment of a `StoreServiceConfig` or
748/// `DeleteServiceConfig` operation.
749#[non_exhaustive]
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct ServiceConfigAckPayload {
752 /// Correlation ID matching the request.
753 pub request_id: String,
754 /// `true` if the operation succeeded.
755 pub success: bool,
756 /// Error message when `success` is `false`.
757 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub error: Option<String>,
759}
760
761impl ServiceConfigAckPayload {
762 /// Create a new success ACK.
763 pub fn success(request_id: String) -> Self {
764 Self {
765 request_id,
766 success: true,
767 error: None,
768 }
769 }
770
771 /// Create a new error ACK.
772 pub fn error(request_id: String, error: String) -> Self {
773 Self {
774 request_id,
775 success: false,
776 error: Some(error),
777 }
778 }
779}
780
781/// Controller → Service: initial delivery of all stored config entries.
782///
783/// Sent once after the service authenticates (after credential delivery).
784/// The service should use this as its authoritative in-memory state.
785#[non_exhaustive]
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787pub struct ServiceConfigDeliveryPayload {
788 /// All config entries stored for this `service_app_name`.
789 pub entries: Vec<ServiceConfigEntry>,
790}
791
792impl ServiceConfigDeliveryPayload {
793 /// Create a new `ServiceConfigDeliveryPayload`.
794 pub fn new(entries: Vec<ServiceConfigEntry>) -> Self {
795 Self { entries }
796 }
797}
798
799/// Controller → Service: incremental config update notification.
800///
801/// Pushed to all connected instances of the same `service_app_name` when
802/// any instance stores or deletes a config entry. Services should apply
803/// these changes to their in-memory state atomically.
804#[non_exhaustive]
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
806pub struct ServiceConfigUpdatedPayload {
807 /// Entries that were inserted or updated (with decrypted values).
808 #[serde(default, skip_serializing_if = "Vec::is_empty")]
809 pub changed: Vec<ServiceConfigEntry>,
810 /// Keys that were deleted.
811 #[serde(default, skip_serializing_if = "Vec::is_empty")]
812 pub deleted: Vec<ServiceConfigKey>,
813}
814
815impl ServiceConfigUpdatedPayload {
816 /// Create a new `ServiceConfigUpdatedPayload`.
817 pub fn new(changed: Vec<ServiceConfigEntry>, deleted: Vec<ServiceConfigKey>) -> Self {
818 Self { changed, deleted }
819 }
820}
821
822// =============================================================================
823// Infrastructure Credential Payloads
824// =============================================================================
825
826/// Infrastructure credentials for services that advertise credential capabilities.
827///
828/// Fields are populated based on the service's capability set:
829/// - `database_access` → `db_url` is set
830/// - `nats_access` → `nats_url` is set (if controller has NATS)
831/// - `master_key_access` → `master_key_hex` is set (if encryption enabled)
832///
833/// **Security**: This payload contains highly sensitive credentials. It must
834/// NEVER be published to NATS or any external transport. It is delivered
835/// exclusively over the authenticated WebSocket connection.
836#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
837pub struct ServiceCredentialsPayload {
838 /// Database connection URL. Present when the service has `database_access`.
839 #[serde(skip_serializing_if = "Option::is_none")]
840 pub db_url: Option<SecretString>,
841 /// Master encryption key as 64-char hex. Present when the service has
842 /// `master_key_access` and encryption is enabled on the controller.
843 #[serde(skip_serializing_if = "Option::is_none")]
844 pub master_key_hex: Option<SecretString>,
845 /// NATS server URL. Present when the service has `nats_access` and
846 /// NATS is configured on the controller.
847 #[serde(skip_serializing_if = "Option::is_none")]
848 pub nats_url: Option<String>,
849}
850
851/// Request from an external component (e.g. scheduler) for the controller to
852/// perform CA certificate rotation.
853///
854/// Published via NATS to `uptrakit.events.controller` subject. Handled by
855/// triggering `ca_rotation_trigger.notify_one()` on the receiving controller.
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857pub struct RequestCaRotationPayload {
858 /// Human-readable reason for the rotation request.
859 pub reason: String,
860}
861
862/// Request all controller instances to rebuild the CRL immediately.
863///
864/// Published via NATS to the `uptrakit.events.controller` subject by any
865/// controller that revokes a certificate or by the `CrlRenewal` scheduled
866/// task. Receiving controllers fire `revocation_notify.notify_one()`.
867#[non_exhaustive]
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
869pub struct RequestCrlRenewalPayload {}
870
871/// Signal that software states have changed for a tenant and need to be
872/// re-loaded and pushed to update-tracking services.
873///
874/// Published to the `controller` NATS subject by the external scheduler after
875/// a version-check run completes. The receiving controller loads the states
876/// from the database and pushes them to all connected update-tracking services.
877///
878/// This is a lightweight signal — it carries only the tenant ID, not the state
879/// data itself. This decouples the scheduler from the state-loading logic.
880#[non_exhaustive]
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882pub struct SoftwareStatesChangedPayload {
883 pub tenant_id: uuid::Uuid,
884}
885
886impl SoftwareStatesChangedPayload {
887 pub fn new(tenant_id: uuid::Uuid) -> Self {
888 Self { tenant_id }
889 }
890}
891
892/// Cross-controller token revocation event.
893///
894/// Published to the `controller` NATS subject by the controller that wrote
895/// the revocation to the DB. Receiving controllers apply the revocation to
896/// their in-memory denylist only — they do **not** write to DB (the
897/// originating controller already did that).
898///
899/// A message may carry a JTI-level revocation, a user-level revocation, or
900/// both. Fields not relevant to the revocation type are `None`.
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
902pub struct TokenRevokedPayload {
903 /// JWT ID to deny (`exp` must also be set for JTI-level revocations).
904 #[serde(skip_serializing_if = "Option::is_none")]
905 pub jti: Option<String>,
906 /// Token expiry unix timestamp (seconds). Required when `jti` is set.
907 #[serde(skip_serializing_if = "Option::is_none")]
908 pub exp: Option<i64>,
909 /// User UUID for user-level revocations.
910 #[serde(skip_serializing_if = "Option::is_none")]
911 pub user_id: Option<Uuid>,
912 /// Deny tokens with `iat < iat_cutoff`. Required when `user_id` is set.
913 #[serde(skip_serializing_if = "Option::is_none")]
914 pub iat_cutoff: Option<i64>,
915 /// Remove the user entry after this unix timestamp.
916 #[serde(skip_serializing_if = "Option::is_none")]
917 pub purge_after: Option<i64>,
918}
919
920/// Per-host metadata published to MQTT for MQTT-browser visibility and Home Assistant.
921///
922/// Included in [`SoftwareStatesPayload`]. All fields are sourced exclusively
923/// from the shared DB — safe for multi-controller deployments.
924///
925/// Intentionally excludes `ip_address` (network topology risk) and `agent_online`
926/// (must come from the event-driven [`HostConnectivityUpdatedPayload`]).
927#[non_exhaustive]
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929pub struct HostStateMetadata {
930 /// Host UUID.
931 pub host_id: Uuid,
932 /// Hostname as reported by the agent.
933 pub hostname: String,
934 /// User-defined display name.
935 pub friendly_name: String,
936 /// Operating system type (e.g. `"linux"`, `"macos"`). `null` when unknown.
937 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub os_type: Option<String>,
939 /// Operating system version (e.g. `"Ubuntu 24.04 LTS"`). `null` when unknown.
940 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub os_version: Option<String>,
942 /// CPU architecture (e.g. `"x86_64"`, `"aarch64"`). `null` when unknown.
943 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub architecture: Option<String>,
945 /// Organisational tag names assigned to this host (e.g. `["production", "web-server"]`).
946 #[serde(default)]
947 pub tags: Vec<String>,
948 /// Agent binary version string (e.g. `"0.2.1"`). `null` when never connected.
949 ///
950 /// Sourced from `services.client_version` for the newest approved, non-deactivated
951 /// agent linked to this host.
952 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub agent_version: Option<String>,
954 /// ISO 8601 timestamp of when the agent last sent a message.
955 ///
956 /// Sourced from `services.last_seen_at`. `null` when never seen.
957 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub agent_last_seen_at: Option<String>,
959}
960
961impl HostStateMetadata {
962 /// Creates a new `HostStateMetadata` with required fields.
963 pub fn new(host_id: Uuid, hostname: String, friendly_name: String) -> Self {
964 Self {
965 host_id,
966 hostname,
967 friendly_name,
968 os_type: None,
969 os_version: None,
970 architecture: None,
971 tags: Vec::new(),
972 agent_version: None,
973 agent_last_seen_at: None,
974 }
975 }
976}
977
978/// Connectivity status for a single host, used in [`HostConnectivityUpdatedPayload`].
979#[non_exhaustive]
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
981pub struct HostConnectivityUpdate {
982 /// Host UUID.
983 pub host_id: Uuid,
984 /// Whether the agent is currently connected (`true` = online, `false` = offline).
985 pub online: bool,
986 /// Timestamp of last agent activity (ISO 8601). `null` when unavailable.
987 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub last_seen_at: Option<String>,
989 /// Agent binary version. Present on connect; `null` on disconnect.
990 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub agent_version: Option<String>,
992}
993
994impl HostConnectivityUpdate {
995 /// Creates an online update.
996 pub fn online(
997 host_id: Uuid,
998 last_seen_at: Option<String>,
999 agent_version: Option<String>,
1000 ) -> Self {
1001 Self {
1002 host_id,
1003 online: true,
1004 last_seen_at,
1005 agent_version,
1006 }
1007 }
1008
1009 /// Creates an offline update.
1010 pub fn offline(host_id: Uuid, last_seen_at: Option<String>) -> Self {
1011 Self {
1012 host_id,
1013 online: false,
1014 last_seen_at,
1015 agent_version: None,
1016 }
1017 }
1018}
1019
1020/// Controller → MQTT service: agent connectivity changed for one or more hosts.
1021///
1022/// Published to NATS with `target_capability = "update_tracking"` so that the MQTT
1023/// service on whichever controller the agent is connected to broadcasts the
1024/// connectivity state to **all** MQTT services across the cluster. This is the
1025/// canonical source of truth for `{prefix}/hosts/{h}/connectivity/state`.
1026///
1027/// Multi-controller safety: published by the controller that owns the agent
1028/// WebSocket connection (the only one with authoritative live state). All other
1029/// controllers receive this via NATS and update their caches.
1030#[non_exhaustive]
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032pub struct HostConnectivityUpdatedPayload {
1033 /// Tenant this update belongs to.
1034 pub tenant_id: Uuid,
1035 /// One entry per host whose connectivity changed.
1036 pub updates: Vec<HostConnectivityUpdate>,
1037}
1038
1039impl HostConnectivityUpdatedPayload {
1040 /// Creates a new payload.
1041 pub fn new(tenant_id: Uuid, updates: Vec<HostConnectivityUpdate>) -> Self {
1042 Self { tenant_id, updates }
1043 }
1044}
1045
1046/// Pagination metadata for a [`SoftwareStatesPayload`] message.
1047///
1048/// All payloads carry a `page` field. For single-page delivery use
1049/// `{ page_index: 0, total_pages: 1 }`. Multi-page delivery uses
1050/// `page_index` 0…N-1; the last page satisfies `page_index + 1 == total_pages`.
1051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1052pub struct SoftwareStatesPage {
1053 /// Zero-based index of this page.
1054 pub page_index: u32,
1055 /// Total number of pages in this delivery batch.
1056 pub total_pages: u32,
1057}
1058
1059impl SoftwareStatesPage {
1060 /// Creates a single-page marker (the only page in a single-page delivery).
1061 pub fn single() -> Self {
1062 Self {
1063 page_index: 0,
1064 total_pages: 1,
1065 }
1066 }
1067}
1068
1069/// Controller -> MQTT service: current software version state for a tenant.
1070///
1071/// Sent after tenant assignment and after any version check or update result.
1072/// Safe to write to the outbox (contains no credentials).
1073///
1074/// Large tenants use multi-page delivery. The `page` field indicates which
1075/// page this payload represents. Receivers must accumulate all pages before
1076/// applying the full state update (see `page_index + 1 == total_pages` for
1077/// the last-page signal).
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079pub struct SoftwareStatesPayload {
1080 /// Tenant this state belongs to.
1081 pub tenant_id: Uuid,
1082 /// All active software items for the tenant with per-host version data.
1083 pub items: Vec<SoftwareStateItem>,
1084 /// Per-host aggregate summary of unpinned (unfeatured) software items.
1085 ///
1086 /// Each entry summarises all enabled, non-deactivated unfeatured items for
1087 /// one host. Only hosts with at least one such item are included.
1088 /// Defaults to an empty list on deserialization for backward compatibility
1089 /// with older MQTT services.
1090 #[serde(default, alias = "host_package_hosts")]
1091 pub host_summaries: Vec<HostPackageSummary>,
1092 /// Per-host metadata for all hosts referenced in `items` or `host_summaries`.
1093 ///
1094 /// Includes OS info, tags, and agent last-seen data. Sourced exclusively from DB.
1095 /// Defaults to an empty list for backward compatibility with older MQTT services.
1096 #[serde(default)]
1097 pub hosts: Vec<HostStateMetadata>,
1098 /// Pagination metadata indicating which page this payload represents.
1099 pub page: SoftwareStatesPage,
1100}
1101
1102/// A single software item entry in [`SoftwareStatesPayload`].
1103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104pub struct SoftwareStateItem {
1105 /// Software item UUID.
1106 pub software_item_id: Uuid,
1107 /// Human-readable software item name.
1108 pub name: String,
1109 /// Optional HTTPS URL to an icon/logo image.
1110 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 pub icon_url: Option<String>,
1112 /// Per-host version data for this software item.
1113 pub hosts: Vec<SoftwareStateHostEntry>,
1114}
1115
1116/// Per-host version data for a software item.
1117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1118pub struct SoftwareStateHostEntry {
1119 /// Host UUID.
1120 pub host_id: Uuid,
1121 /// Human-readable hostname.
1122 pub hostname: String,
1123 /// User-defined display name for the host.
1124 pub friendly_name: String,
1125 /// Currently installed version, if known.
1126 #[serde(default, skip_serializing_if = "Option::is_none")]
1127 pub installed_version: Option<String>,
1128 /// Latest available version, if known.
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub latest_version: Option<String>,
1131 /// Whether an update is available (`latest_version > installed_version`).
1132 pub update_available: bool,
1133 /// Whether an update is currently pending or in progress for this host-item pair.
1134 ///
1135 /// Set to `true` when an `update_history` record exists with status
1136 /// `Pending` or `InProgress`. Cleared to `false` once the update
1137 /// completes or fails. Defaults to `false` when absent (older controller).
1138 #[serde(default)]
1139 pub update_in_progress: bool,
1140 /// URL to the upstream release page (e.g. GitHub release), if available.
1141 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub release_url: Option<String>,
1143 /// Release notes or changelog text, if available.
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub release_notes: Option<String>,
1146 /// Classification of the update (e.g. `"security"`, `"bugfix"`, `"feature"`, `"unknown"`).
1147 ///
1148 /// Sourced from `host_software_item.update_category`. Defaults to `"unknown"` when absent.
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub update_category: Option<String>,
1151 /// Date when the latest release was published (ISO 8601 date string, e.g. `"2025-01-15"`).
1152 ///
1153 /// Extracted from `latest_release_metadata.published_at`. `null` when metadata is absent.
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub release_date: Option<String>,
1156 /// Timestamp when the installed version was last detected (ISO 8601).
1157 ///
1158 /// Sourced from `host_software_item.installed_version_detected_at`. `null` when never checked.
1159 #[serde(default, skip_serializing_if = "Option::is_none")]
1160 pub last_checked_at: Option<String>,
1161}
1162
1163/// Service -> Controller: request to trigger a software update.
1164///
1165/// Sent when a Home Assistant user presses "Install" on an update entity.
1166/// The controller validates and dispatches the update to the appropriate agent.
1167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1168pub struct ServiceUpdateTriggerPayload {
1169 /// Tenant UUID (for validation).
1170 pub tenant_id: Uuid,
1171 /// Software item to update.
1172 pub software_item_id: Uuid,
1173 /// Host to update on.
1174 pub host_id: Uuid,
1175 /// Target version to install.
1176 pub to_version: String,
1177 /// Service instance UUID that initiated the trigger (used as actor_id).
1178 pub actor_service_id: Uuid,
1179}
1180
1181/// Per-host aggregate summary of unpinned (unfeatured) software items.
1182///
1183/// Included in [`SoftwareStatesPayload`] to surface overall update
1184/// status per host to Home Assistant via a single `update` entity per host.
1185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1186pub struct HostPackageSummary {
1187 /// Host UUID.
1188 pub host_id: Uuid,
1189 /// Human-readable hostname.
1190 pub hostname: String,
1191 /// User-defined display name for the host.
1192 #[serde(default)]
1193 pub friendly_name: String,
1194 /// Count of items where `installed_version != latest_version` (both known).
1195 pub pending_count: u32,
1196 /// Count of items where `update_category = "security"` AND versions differ.
1197 pub security_pending_count: u32,
1198 /// Total count of enabled, non-deactivated unfeatured items for this host.
1199 pub total_count: u32,
1200 /// Whether a batch update is currently pending or in progress for this host.
1201 pub update_in_progress: bool,
1202 /// Count of pending packages where `update_category = "bugfix"`.
1203 ///
1204 /// Defaults to `0` when absent (older controller that does not compute this field).
1205 #[serde(default)]
1206 pub bugfix_count: u32,
1207 /// Count of pending packages where `update_category = "feature"`.
1208 ///
1209 /// Defaults to `0` when absent (older controller that does not compute this field).
1210 #[serde(default)]
1211 pub feature_count: u32,
1212}
1213
1214/// Service → Controller: trigger a batch update of all outdated software items on a host.
1215///
1216/// Sent when a Home Assistant user presses "Install" on a host update
1217/// entity. The controller resolves the latest versions for all outdated items
1218/// at trigger time and dispatches a `ExecuteBatchUpdate` to the agent.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220pub struct ServiceHostBatchUpdateTriggerPayload {
1221 /// Tenant UUID (for validation).
1222 pub tenant_id: Uuid,
1223 /// Host whose items should be updated.
1224 pub host_id: Uuid,
1225 /// Service instance UUID that initiated the trigger (used as actor_id).
1226 pub actor_service_id: Uuid,
1227 /// When `true`, only items with `update_category = "security"` are updated.
1228 #[serde(default)]
1229 pub security_only: bool,
1230}
1231
1232/// Service -> Controller: forwarded semantic audit event.
1233///
1234/// The wire payload intentionally keeps semantic fields as strings so the
1235/// controller can re-validate them against its canonical audit contract before
1236/// persisting or exporting the event.
1237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1238pub struct AuditEventPayload {
1239 /// Semantic action identifier, such as `service.certificate.issue`.
1240 pub action_type: String,
1241 /// Tenant UUID as a string when the event is tenant-scoped.
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub tenant_id: Option<String>,
1244 /// Optional semantic target type.
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub target_type: Option<String>,
1247 /// Optional semantic target identifier.
1248 #[serde(default, skip_serializing_if = "Option::is_none")]
1249 pub target_id: Option<String>,
1250 /// Optional human-readable target display value.
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub target_display: Option<String>,
1253 /// Semantic outcome, such as `success` or `denied`.
1254 pub outcome: String,
1255 /// Optional JSON-encoded details payload.
1256 #[serde(default, skip_serializing_if = "Option::is_none")]
1257 pub details_json: Option<String>,
1258 /// Optional correlation identifier.
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1260 pub request_id: Option<String>,
1261}
1262
1263// =============================================================================
1264// Software Autodiscovery Payloads
1265// =============================================================================
1266
1267/// Controller -> Agent: Run software discovery on the given host.
1268///
1269/// The `plugins` list contains one entry per plugin that should be used.
1270/// When `plugin_config_id` is `None`, the assignment uses a default (empty)
1271/// config — the controller will auto-create a `PluginConfig` record once
1272/// results arrive.
1273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1274pub struct DiscoverSoftwarePayload {
1275 /// Machine ID of the host to discover software on.
1276 ///
1277 /// For the regular agent this is validated to match its own machine_id.
1278 /// For the SSH agent it identifies which remote host to connect to.
1279 pub host_machine_id: String,
1280 /// Per-plugin discovery assignments.
1281 pub plugins: Vec<DiscoveryPluginAssignment>,
1282}
1283
1284/// A single plugin assignment inside a [`DiscoverSoftwarePayload`].
1285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1286pub struct DiscoveryPluginAssignment {
1287 /// Pre-existing plugin config ID, or `None` for a default/auto run.
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1289 pub plugin_config_id: Option<Uuid>,
1290 /// Plugin type to use for discovery.
1291 pub plugin_type: PluginTypeId,
1292 /// Plugin-specific configuration (`{}` for default assignments).
1293 pub config: serde_json::Value,
1294}
1295
1296/// Agent -> Controller: Results of a software discovery run.
1297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1298pub struct DiscoveryResultsPayload {
1299 /// Machine ID of the host that was scanned (echoed from the assignment).
1300 pub host_machine_id: String,
1301 /// Per-plugin results.
1302 pub results: Vec<DiscoveryPluginResult>,
1303}
1304
1305/// Result for a single plugin inside a [`DiscoveryResultsPayload`].
1306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1307pub struct DiscoveryPluginResult {
1308 /// Echoed from [`DiscoveryPluginAssignment`] so the controller can route
1309 /// results to the correct `PluginConfig` row.
1310 #[serde(default, skip_serializing_if = "Option::is_none")]
1311 pub plugin_config_id: Option<Uuid>,
1312 /// Plugin type that produced these results.
1313 pub plugin_type: PluginTypeId,
1314 /// Discovered software items (empty on error).
1315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316 pub discoveries: Vec<DiscoveredSoftware>,
1317 /// Plugin-level error message, if discovery failed.
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
1319 pub error: Option<String>,
1320}
1321
1322// =============================================================================
1323// Plugin config reporting
1324// =============================================================================
1325
1326/// Payload for `ServiceMessage::ReportPluginConfig`.
1327///
1328/// Sent by agents that detect infrastructure (e.g. PVE nodes) during bootstrap
1329/// and want the controller to create or retrieve a plugin configuration.
1330#[non_exhaustive]
1331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1332pub struct ReportPluginConfigPayload {
1333 /// Unique request identifier for correlating the response.
1334 pub request_id: String,
1335 /// Plugin type string (e.g. `"infrastructure_proxmox"`).
1336 pub plugin_type: String,
1337 /// Human-readable name for the config (e.g. `"pve.local"`).
1338 pub name: String,
1339 /// Plugin-specific configuration JSON.
1340 pub config: serde_json::Value,
1341}
1342
1343/// Payload for `ControllerMessage::ReportPluginConfigResponse`.
1344///
1345/// Returned to a service in response to `ReportPluginConfig`. Idempotent:
1346/// if a config with the same `(tenant_id, plugin_type, name)` already exists,
1347/// the existing ID is returned without creating a duplicate.
1348#[non_exhaustive]
1349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1350pub struct ReportPluginConfigResponsePayload {
1351 /// The request ID from the original `ReportPluginConfig` message.
1352 pub request_id: String,
1353 /// Whether the operation succeeded.
1354 pub success: bool,
1355 /// The plugin config ID (set on success).
1356 #[serde(default, skip_serializing_if = "Option::is_none")]
1357 pub plugin_config_id: Option<Uuid>,
1358 /// Error message (set on failure).
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1360 pub error: Option<String>,
1361}
1362
1363// =============================================================================
1364// Interactive Update Payloads
1365// =============================================================================
1366
1367/// Controller → Agent: forward stdin data or a signal to a running interactive update.
1368///
1369/// The `data` field contains raw bytes encoded as base64 to support binary
1370/// control sequences (e.g., `\x03` for Ctrl+C). When `signal` is set, the
1371/// agent delivers the signal to the process group instead of writing stdin.
1372#[non_exhaustive]
1373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1374pub struct UpdateStdinDataPayload {
1375 /// The update history record this stdin data belongs to.
1376 pub update_history_id: Uuid,
1377 /// Raw bytes encoded as base64 (supports binary: Ctrl+C = \x03, etc.).
1378 pub data: String,
1379 /// When set, send this signal to the process group instead of writing stdin.
1380 /// Values: 2 = SIGINT, 15 = SIGTERM.
1381 #[serde(default, skip_serializing_if = "Option::is_none")]
1382 pub signal: Option<i32>,
1383}
1384
1385impl UpdateStdinDataPayload {
1386 /// Create a new stdin data payload.
1387 pub fn new(update_history_id: Uuid, data: String) -> Self {
1388 Self {
1389 update_history_id,
1390 data,
1391 signal: None,
1392 }
1393 }
1394
1395 /// Create a new signal payload.
1396 pub fn with_signal(update_history_id: Uuid, signal: i32) -> Self {
1397 Self {
1398 update_history_id,
1399 data: String::new(),
1400 signal: Some(signal),
1401 }
1402 }
1403}
1404
1405/// Agent → Controller: the update process appears to be waiting for stdin input.
1406///
1407/// Sent when the agent detects sustained silence from the process (no output for
1408/// ~10 seconds while still running). The controller broadcasts this to interactive
1409/// session subscribers and may trigger notifications.
1410#[non_exhaustive]
1411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1412pub struct StdinAttentionPayload {
1413 /// The update history record that needs attention.
1414 pub update_history_id: Uuid,
1415 /// Optional hint about what the process might be waiting for.
1416 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub hint: Option<String>,
1418}
1419
1420impl StdinAttentionPayload {
1421 /// Create a new attention payload.
1422 pub fn new(update_history_id: Uuid) -> Self {
1423 Self {
1424 update_history_id,
1425 hint: None,
1426 }
1427 }
1428
1429 /// Create a new attention payload with a hint.
1430 pub fn with_hint(update_history_id: Uuid, hint: String) -> Self {
1431 Self {
1432 update_history_id,
1433 hint: Some(hint),
1434 }
1435 }
1436}
1437
1438// --- Cross-controller admin event broadcast ---
1439
1440/// Cross-controller admin event broadcast payload.
1441///
1442/// Published via NATS to the `controller` subject by any controller instance
1443/// when it emits an [`AdminEvent`](uptrakit_web_api_types::events::AdminEvent)
1444/// to local SSE subscribers. Receiving controller instances decode the payload
1445/// and re-broadcast to their own local SSE subscribers without re-publishing
1446/// to NATS (to avoid infinite loops).
1447///
1448/// `tenant_id = None` means the event targets all tenants (system-wide).
1449///
1450/// **Safe to publish via NATS** — contains no credential material.
1451#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1452pub struct BroadcastAdminEventPayload {
1453 /// Target tenant, or `None` for system-wide events.
1454 pub tenant_id: Option<Uuid>,
1455 /// JSON-serialised `AdminEvent`.
1456 pub event_json: String,
1457}
1458
1459// =============================================================================
1460// Workload Claim Protocol Payloads
1461// =============================================================================
1462
1463/// Service → Controller: request exclusive ownership of config keys.
1464///
1465/// Each key in `claims` is a config key (e.g. `"clients.{uuid}"`) and the
1466/// value is the `tenant_id` that config belongs to. The controller grants
1467/// unclaimed keys and rejects keys already claimed by another service.
1468///
1469/// Uses **full replacement semantics**: each `WorkloadClaim` sends the
1470/// complete desired config key set. The controller diffs against current
1471/// grants to determine what to claim/release.
1472#[non_exhaustive]
1473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1474pub struct WorkloadClaimPayload {
1475 /// Map of `config_key → tenant_id` representing the full desired set.
1476 pub claims: BTreeMap<String, Uuid>,
1477}
1478
1479impl WorkloadClaimPayload {
1480 /// Create a new `WorkloadClaimPayload`.
1481 pub fn new(claims: BTreeMap<String, Uuid>) -> Self {
1482 Self { claims }
1483 }
1484}
1485
1486/// Controller → Service: grant/reject response for a workload claim request.
1487///
1488/// Sent in response to `WorkloadClaim`, or unsolicited when the controller
1489/// proactively re-grants previously rejected keys that became available
1490/// (e.g. after another service disconnected), or when revoking keys due
1491/// to cross-controller conflict resolution.
1492#[non_exhaustive]
1493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1494pub struct WorkloadClaimResultPayload {
1495 /// Config keys that were granted (exclusive ownership).
1496 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1497 pub granted: BTreeSet<String>,
1498 /// Config keys that were rejected (already claimed by another service).
1499 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1500 pub rejected: BTreeSet<String>,
1501}
1502
1503impl WorkloadClaimResultPayload {
1504 /// Create a new `WorkloadClaimResultPayload`.
1505 pub fn new(granted: BTreeSet<String>, rejected: BTreeSet<String>) -> Self {
1506 Self { granted, rejected }
1507 }
1508}
1509
1510/// Service → Controller: voluntarily release config keys.
1511///
1512/// Sent when a service no longer wants to serve certain configs (e.g. after
1513/// a config deletion). The controller releases the keys and makes them
1514/// available for other services.
1515#[non_exhaustive]
1516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1517pub struct WorkloadReleasePayload {
1518 /// Config keys to release.
1519 pub keys: BTreeSet<String>,
1520}
1521
1522impl WorkloadReleasePayload {
1523 /// Create a new `WorkloadReleasePayload`.
1524 pub fn new(keys: BTreeSet<String>) -> Self {
1525 Self { keys }
1526 }
1527}
1528
1529/// Controller → NATS: announce claim state changes for cross-controller sync.
1530///
1531/// Published to the `controller` NATS subject after granting or releasing
1532/// claims. Other controllers update their global claim registry from this.
1533///
1534/// **Safe to publish via NATS** — contains no credential material.
1535#[non_exhaustive]
1536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1537pub struct WorkloadClaimAnnouncementPayload {
1538 /// The service that owns these claims.
1539 pub service_id: Uuid,
1540 /// The controller that granted these claims.
1541 pub controller_id: Uuid,
1542 /// Newly claimed keys: `config_key → tenant_id`.
1543 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1544 pub claimed: BTreeMap<String, Uuid>,
1545 /// Keys that were released.
1546 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1547 pub released: BTreeSet<String>,
1548 /// ISO 8601 timestamp when the claims were granted (for conflict resolution).
1549 pub claimed_at: String,
1550}
1551
1552impl WorkloadClaimAnnouncementPayload {
1553 /// Create a new `WorkloadClaimAnnouncementPayload`.
1554 pub fn new(
1555 service_id: Uuid,
1556 controller_id: Uuid,
1557 claimed: BTreeMap<String, Uuid>,
1558 released: BTreeSet<String>,
1559 claimed_at: String,
1560 ) -> Self {
1561 Self {
1562 service_id,
1563 controller_id,
1564 claimed,
1565 released,
1566 claimed_at,
1567 }
1568 }
1569}
1570
1571/// Controller → NATS: request full claim state from all active controllers.
1572///
1573/// Published on controller startup to the `controller` NATS subject.
1574/// Each active controller responds with `WorkloadClaimSyncResponse`.
1575///
1576/// **NATS-only** (controller-to-controller), not service-facing.
1577#[non_exhaustive]
1578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1579pub struct WorkloadClaimSyncRequestPayload {
1580 /// The requesting controller's ID.
1581 pub controller_id: Uuid,
1582}
1583
1584impl WorkloadClaimSyncRequestPayload {
1585 /// Create a new `WorkloadClaimSyncRequestPayload`.
1586 pub fn new(controller_id: Uuid) -> Self {
1587 Self { controller_id }
1588 }
1589}
1590
1591/// Controller → NATS: respond with full local claim state.
1592///
1593/// Sent in response to `WorkloadClaimSyncRequest`. Contains the responding
1594/// controller's complete local claim map for merging into the requester's
1595/// global registry.
1596///
1597/// **NATS-only** (controller-to-controller), not service-facing.
1598#[non_exhaustive]
1599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1600pub struct WorkloadClaimSyncResponsePayload {
1601 /// The responding controller's ID.
1602 pub controller_id: Uuid,
1603 /// Full local claim state: `config_key → (service_id, tenant_id)`.
1604 pub claims: BTreeMap<String, WorkloadClaimSyncEntry>,
1605}
1606
1607impl WorkloadClaimSyncResponsePayload {
1608 /// Create a new `WorkloadClaimSyncResponsePayload`.
1609 pub fn new(controller_id: Uuid, claims: BTreeMap<String, WorkloadClaimSyncEntry>) -> Self {
1610 Self {
1611 controller_id,
1612 claims,
1613 }
1614 }
1615}
1616
1617/// A single entry in a `WorkloadClaimSyncResponse`.
1618#[non_exhaustive]
1619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1620pub struct WorkloadClaimSyncEntry {
1621 /// The service that owns this claim.
1622 pub service_id: Uuid,
1623 /// The tenant this config key belongs to.
1624 pub tenant_id: Uuid,
1625 /// ISO 8601 timestamp when the claim was granted.
1626 pub claimed_at: String,
1627}
1628
1629impl WorkloadClaimSyncEntry {
1630 /// Create a new `WorkloadClaimSyncEntry`.
1631 pub fn new(service_id: Uuid, tenant_id: Uuid, claimed_at: String) -> Self {
1632 Self {
1633 service_id,
1634 tenant_id,
1635 claimed_at,
1636 }
1637 }
1638}
1639
1640// ── Config test payloads ─────────────────────────────────────────────────────
1641
1642// Must be `pub use`, not bare `use` — wire's lib.rs does `pub use payloads::*`,
1643// which only re-exports *pub* items. A private import here would silently drop
1644// ConfigTestKind from the wire public API and break all 21 non-plugin dependents.
1645pub use uptrakit_shared_types::ConfigTestKind;
1646
1647/// Payload for a plugin configuration test request (controller -> agent).
1648#[non_exhaustive]
1649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1650pub struct TestPluginConfigPayload {
1651 /// Unique request ID for correlation (UUID v7).
1652 pub request_id: String,
1653 /// Target host machine ID on the agent.
1654 pub host_machine_id: String,
1655 /// What to test.
1656 pub test_kind: ConfigTestKind,
1657 /// The plugin type to test.
1658 pub plugin_type: String,
1659 /// The plugin configuration JSON to test.
1660 pub config: serde_json::Value,
1661 /// Package identifier for testing (required for version detection).
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub package_identifier: Option<String>,
1664}
1665
1666impl TestPluginConfigPayload {
1667 /// Creates a new test plugin config payload.
1668 pub fn new(
1669 request_id: String,
1670 host_machine_id: String,
1671 test_kind: ConfigTestKind,
1672 plugin_type: String,
1673 config: serde_json::Value,
1674 ) -> Self {
1675 Self {
1676 request_id,
1677 host_machine_id,
1678 test_kind,
1679 plugin_type,
1680 config,
1681 package_identifier: None,
1682 }
1683 }
1684}
1685
1686/// Payload for a plugin configuration test result (agent -> controller).
1687#[non_exhaustive]
1688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1689pub struct TestPluginConfigResultPayload {
1690 /// Correlation ID matching the original request.
1691 pub request_id: String,
1692 /// Whether the test passed.
1693 pub success: bool,
1694 /// Command output or connectivity response.
1695 #[serde(default, skip_serializing_if = "Option::is_none")]
1696 pub output: Option<String>,
1697 /// Error message if the test failed.
1698 #[serde(default, skip_serializing_if = "Option::is_none")]
1699 pub error: Option<String>,
1700 /// Detected version (for version detection tests).
1701 #[serde(default, skip_serializing_if = "Option::is_none")]
1702 pub detected_version: Option<String>,
1703 /// Test duration in milliseconds.
1704 pub duration_ms: u64,
1705}
1706
1707impl TestPluginConfigResultPayload {
1708 /// Creates a new test result payload.
1709 pub fn new(request_id: String, success: bool, duration_ms: u64) -> Self {
1710 Self {
1711 request_id,
1712 success,
1713 output: None,
1714 error: None,
1715 detected_version: None,
1716 duration_ms,
1717 }
1718 }
1719}