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