Skip to main content

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}