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