Skip to main content

zlayer_types/api/
cluster.rs

1//! Cluster join / membership wire DTOs.
2//!
3//! Lifted from `zlayer-api::handlers::cluster` so the CLI, the manager UI,
4//! and any other client can describe these requests/responses without
5//! depending on `zlayer-api`. The handler itself stays in `zlayer-api`.
6
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use crate::api::nodes::GpuInfoSummary;
11use crate::spec::{ArchKind, OsKind};
12
13/// Request body for `POST /api/v1/cluster/join`.
14#[derive(Debug, Clone, Deserialize, ToSchema)]
15pub struct ClusterJoinRequest {
16    /// Base64-encoded join token (contains `auth_secret` for validation)
17    pub token: String,
18    /// Joining node's advertise address (IP)
19    pub advertise_addr: String,
20    /// Joining node's overlay port (`WireGuard`)
21    pub overlay_port: u16,
22    /// Joining node's Raft RPC port
23    pub raft_port: u16,
24    /// Joining node's API server port
25    #[serde(default = "default_api_port")]
26    pub api_port: u16,
27    /// Joining node's `WireGuard` public key
28    pub wg_public_key: String,
29    /// Node mode: "full" or "replicate"
30    #[serde(default = "default_mode")]
31    pub mode: String,
32    /// Services to replicate (only if mode == "replicate")
33    pub services: Option<Vec<String>>,
34    /// Total CPU cores on the joining node
35    #[serde(default)]
36    pub cpu_total: f64,
37    /// Total memory in bytes
38    #[serde(default)]
39    pub memory_total: u64,
40    /// Total disk in bytes
41    #[serde(default)]
42    pub disk_total: u64,
43    /// Detected GPUs
44    #[serde(default)]
45    pub gpus: Vec<GpuInfoSummary>,
46    /// Operating system of the joining agent. `None` = legacy client that did
47    /// not report platform info.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub os: Option<OsKind>,
50    /// CPU architecture of the joining agent. Same legacy semantics as `os`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub arch: Option<ArchKind>,
53    /// Free-form labels advertised by the joining agent, used for
54    /// `NodeSelector` placement matching. Empty on legacy clients.
55    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
56    pub labels: std::collections::HashMap<String, String>,
57    /// Joiner's 32-byte X25519 pubkey for sealed-box DEK wrapping.
58    /// Present on Phase-1+ joiners; absent on legacy clients (in which
59    /// case the leader treats the node as not eligible to host
60    /// replicated-secret ciphertext until it re-joins with a pubkey).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub secrets_pubkey: Option<[u8; 32]>,
63}
64
65#[must_use]
66pub fn default_mode() -> String {
67    "full".to_string()
68}
69
70#[must_use]
71pub fn default_api_port() -> u16 {
72    3669
73}
74
75/// Response body for `POST /api/v1/cluster/join`.
76#[derive(Debug, Clone, Serialize, ToSchema)]
77pub struct ClusterJoinResponse {
78    /// Assigned node UUID
79    pub node_id: String,
80    /// Assigned Raft node ID (monotonic u64)
81    pub raft_node_id: u64,
82    /// Assigned overlay IP for the new node
83    pub overlay_ip: String,
84    /// Per-node slice CIDR assigned by the leader (e.g. "10.200.42.0/28").
85    /// Empty string if the leader is not slice-aware yet.
86    #[serde(default)]
87    pub slice_cidr: String,
88    /// Existing peers in the cluster
89    pub peers: Vec<ClusterPeer>,
90    /// Role assigned to this node: "voter" or "learner"
91    pub role: String,
92    /// Node JWT minted by the leader for this joiner — `roles: ["node"]`,
93    /// `node_id` set. Used to authenticate inter-node calls separately
94    /// from any user identity. `None` on legacy responses.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub node_jwt: Option<String>,
97    /// Sealed-box-wrapped copy of the cluster DEK addressed to the
98    /// joiner's `secrets_pubkey`. The joiner unwraps with its node X25519
99    /// private key and holds the DEK in zeroized memory. `None` on legacy
100    /// responses or when the joiner did not provide a `secrets_pubkey`.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub wrapped_dek: Option<Vec<u8>>,
103    /// Cluster DEK generation that `wrapped_dek` was sealed under. Lets
104    /// the joiner detect rotation drift if it re-joins after a revocation
105    /// rotated the cluster DEK. `None` when `wrapped_dek` is `None`.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub dek_generation: Option<u64>,
108    /// Cluster-wide HMAC join secret. Returned to authenticated joiners
109    /// so they can derive the same internal RPC bearer as the leader.
110    /// `None` on legacy responses from older leaders.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub join_secret: Option<String>,
113    /// Server-side advisory warnings to surface to the operator/CLI.
114    ///
115    /// Examples: "your token format is deprecated and will be removed in
116    /// release X.Y", "consider rotating the signing key, last rotated N
117    /// days ago". Present-but-empty means "no warnings"; serialized as
118    /// `null` (skip-if-none) when there are none.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub warnings: Option<Vec<String>>,
121}
122
123/// Summary of an existing cluster peer returned in join response.
124#[derive(Debug, Clone, Serialize, ToSchema)]
125pub struct ClusterPeer {
126    /// UUID
127    pub node_id: String,
128    /// Raft node ID
129    pub raft_node_id: u64,
130    /// Advertise address
131    pub advertise_addr: String,
132    /// Overlay port
133    pub overlay_port: u16,
134    /// Raft port
135    pub raft_port: u16,
136    /// `WireGuard` public key
137    pub wg_public_key: String,
138    /// Overlay IP
139    pub overlay_ip: String,
140}
141
142/// Claims carried inside a signed cluster join token.
143///
144/// **Field declaration order is the canonical signing order.** Do NOT
145/// reorder these fields without bumping the envelope's `v` and writing
146/// a migration — Wave 3.2's `mint_signed_cluster_join_token` signs
147/// `serde_json::to_vec(&claims)` directly, which depends on the
148/// declaration order being stable.
149///
150/// Timestamps are RFC3339 strings (not Unix epoch) so a token printed
151/// to a wiki or chat log is human-readable. `chrono::DateTime<Utc>` would
152/// also work; we chose `String` to keep the wire format trivially
153/// inspectable with `base64 -d | jq .`.
154#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
155pub struct ClusterJoinClaims {
156    /// Public API endpoint of the issuing leader (e.g. `https://leader.prod:3669`).
157    pub api_endpoint: String,
158    /// Raft endpoint of the issuing leader (e.g. `10.0.0.1:3670`).
159    pub raft_endpoint: String,
160    /// `WireGuard` public key of the issuing leader (base64 standard, no-pad).
161    pub leader_wg_pubkey: String,
162    /// Overlay CIDR the cluster owns (e.g. `10.42.0.0/16`).
163    pub overlay_cidr: String,
164    /// Expiration as RFC3339, e.g. `2026-05-15T17:55:00Z`.
165    pub exp: String,
166    /// Issued-at as RFC3339.
167    pub iat: String,
168    /// Issuing leader node identity. In Wave 3 this is the raw node UUID;
169    /// Wave 9 will switch this to a `spiffe://<cluster_domain>/<node_id>` URI
170    /// (token format version bump). Verifiers in Wave 3 treat `iss` as opaque
171    /// metadata — no parsing required.
172    pub iss: String,
173}
174
175/// Envelope around `ClusterJoinClaims` carrying the Ed25519 signature.
176///
177/// On the wire, this struct is serialized as JSON and then base64
178/// url-safe-no-pad encoded. The Wave 3.2 mint function produces that
179/// outer base64; the parser reverses it.
180#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
181pub struct SignedClusterJoinToken {
182    /// Format version. `1` in Wave 3. Wave 9 will introduce `v=2` (adds
183    /// a `ca_chain` field for federated trust); parsers MUST reject any
184    /// version they don't understand.
185    pub v: u32,
186    /// Key identifier (first 8 hex chars of SHA-256 over the verifying
187    /// key bytes). Lets joining nodes pick the correct pubkey during
188    /// rotation (Wave 5).
189    pub kid: String,
190    /// The payload that's actually signed.
191    pub claims: ClusterJoinClaims,
192    /// Ed25519 signature over `serde_json::to_vec(&claims)`, encoded as
193    /// URL-safe no-pad base64.
194    pub sig: String,
195    /// Optional CA chain binding the `kid` to a foreign cluster. Set
196    /// to `Some(...)` only on v=2 tokens minted for cross-cluster
197    /// federation; same-cluster v=2 tokens may omit it. v=1 tokens
198    /// MUST have this field absent (`skip_serializing_if` guarantees the
199    /// JSON shape stays compatible with v=1 parsers).
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub ca_chain: Option<CaCert>,
202}
203
204/// Current envelope version Wave-3 mints. Re-export so mint and verify
205/// stay in lockstep without a stringly-typed constant elsewhere.
206pub const SIGNED_TOKEN_V_WAVE3: u32 = 1;
207
208/// Wave 9 envelope version: extends Wave 3 with an optional `ca_chain`
209/// so a foreign-issued token can carry the CA-signed binding that
210/// proves its `kid` was issued by the cluster identified in
211/// `ca_chain.cluster_domain`. v=1 tokens still parse — `ca_chain` is
212/// just absent in their JSON.
213pub const SIGNED_TOKEN_V_WAVE9: u32 = 2;
214
215/// "CA certificate" minted by the cluster CA at every rotation of the
216/// active signing key.
217///
218/// Provides the binding: `active_kid` was issued by the cluster whose
219/// `ca_public_key_b64` is published in this cluster's `TrustBundle`.
220/// The signature `sig_by_ca` is the CA's Ed25519 signature over
221/// `serde_json::to_vec(&CaCertCore { active_kid, active_pubkey_b64,
222/// issued_at, expires_at, cluster_domain })` (i.e. the same struct
223/// with the `sig_by_ca` field stripped).
224///
225/// Field declaration order is canonical for signing. Do NOT reorder
226/// without bumping `CA_CERT_FORMAT_VERSION` and adding a migration.
227#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
228pub struct CaCert {
229    /// Format version for the CA cert body. `1` today.
230    pub v: u32,
231    /// `kid` of the active signing key this cert is binding.
232    pub active_kid: String,
233    /// URL-safe no-pad base64 of the active signing key's verifying key.
234    pub active_pubkey_b64: String,
235    /// RFC3339 timestamp when this cert was issued.
236    pub issued_at: String,
237    /// RFC3339 timestamp when this cert expires. Should match the
238    /// active key's own grace expiry so the cert and key share a
239    /// retirement clock.
240    pub expires_at: String,
241    /// Cluster identity this cert binds to. Defaults to the cluster's
242    /// UUID; operators may override to a DNS-style name like
243    /// `prod.zlayer.example`.
244    pub cluster_domain: String,
245    /// Ed25519 signature of the CA over `serde_json::to_vec(&self
246    /// with sig_by_ca cleared)`. URL-safe no-pad base64.
247    pub sig_by_ca: String,
248}
249
250/// Current `CaCert::v` value the issuer emits.
251pub const CA_CERT_FORMAT_VERSION: u32 = 1;
252
253/// Response body for `GET /api/v1/cluster/signing-pubkey`.
254///
255/// Returns the cluster's currently-active Ed25519 verifying key in URL-safe
256/// no-pad base64, along with a short identifier (`kid`) derived from
257/// `SHA-256(verifying_key)[..4]` (first 8 hex chars). Joining nodes use
258/// `kid` to disambiguate during key rotation (Wave 5).
259///
260/// This endpoint is intentionally unauthenticated: the data is a public key.
261#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
262pub struct SigningPubkeyResponse {
263    /// URL-safe no-pad base64 of the 32-byte Ed25519 verifying key.
264    pub public_key_b64: String,
265    /// Short greppable key id: first 8 hex chars of SHA-256(verifying_key).
266    pub kid: String,
267}
268
269/// Per-key entry returned by `GET /api/v1/cluster/signing-pubkeys`.
270#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
271pub struct SigningPubkeyEntry {
272    /// Short key id (8 hex chars).
273    pub kid: String,
274    /// URL-safe no-pad base64 of the Ed25519 verifying key (32 bytes → 43 chars).
275    pub public_key_b64: String,
276    /// `"active"` or `"grace"`. Active = newly-issued tokens use this key.
277    /// Grace = previously active; still verifies in-flight tokens until
278    /// `valid_until`.
279    pub status: String,
280    /// RFC3339 timestamp this key stops being accepted. Only present for
281    /// `status = "grace"`.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub valid_until: Option<String>,
284    /// RFC3339 timestamp this key was created.
285    pub created_at: String,
286}
287
288/// Response body for `GET /api/v1/cluster/signing-pubkeys`.
289///
290/// Returns every currently-trusted Ed25519 verifying key. The first entry
291/// is the active key (the one new tokens are minted under); subsequent
292/// entries are grace-period keys that still accept verification but not
293/// minting. Joining nodes use this when fetching keys for a token whose
294/// `kid` is a previous-active (rotated-out) key.
295///
296/// Unauthenticated by design (matches `signing-pubkey` singular).
297#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
298pub struct SigningPubkeysResponse {
299    pub keys: Vec<SigningPubkeyEntry>,
300}
301
302/// Request body for `POST /api/v1/cluster/rotate-signing-key`.
303///
304/// Triggers a rotation of the cluster's Ed25519 signing keystore: a fresh
305/// keypair is generated, set as the new active key, and the previously
306/// active key is moved into the grace map. Grace-period keys continue to
307/// verify in-flight join tokens until their `valid_until` timestamp.
308#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
309pub struct RotateSigningKeyRequest {
310    /// How long the previous-active key should remain valid for verifying
311    /// in-flight tokens after rotation. Humantime syntax (`24h`, `7d`).
312    /// Defaults to `7d` if omitted.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub grace: Option<String>,
315}
316
317/// Response body for `POST /api/v1/cluster/rotate-signing-key`.
318#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
319pub struct RotateSigningKeyResponse {
320    /// New active key id (8 hex chars).
321    pub kid: String,
322    /// URL-safe no-pad base64 of the new active verifying key.
323    pub public_key_b64: String,
324    /// Previous active kid, now in grace.
325    pub previous_kid: String,
326    /// RFC3339 timestamp when the previous key's grace expires.
327    pub previous_grace_until: String,
328}
329
330/// Public trust bundle for a cluster, distributable out-of-band so
331/// other clusters can import it and accept this cluster's tokens.
332///
333/// Contains the long-lived cluster CA pubkey (not the per-rotation
334/// signing key). Federation works by:
335///   1. Cluster A exports its bundle (`GET /api/v1/cluster/trust-bundle`).
336///   2. Operator transports the bundle to cluster B (out-of-band).
337///   3. Cluster B imports it via the admin endpoint, replicated through
338///      Raft so every node converges.
339///   4. Tokens minted by A's per-rotation key, carrying A's `ca_chain`,
340///      now validate against A's CA pubkey in B's trusted-bundles map.
341#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
342pub struct TrustBundle {
343    /// Format version. `1` today.
344    pub v: u32,
345    /// Cluster identity this bundle represents (defaults to cluster UUID;
346    /// may be a DNS-style domain like `prod.zlayer.example`).
347    pub cluster_domain: String,
348    /// URL-safe no-pad base64 of the cluster CA's Ed25519 verifying key.
349    pub ca_public_key_b64: String,
350    /// Short kid of the CA verifying key (8 hex chars).
351    pub ca_kid: String,
352    /// RFC3339 timestamp of when this bundle snapshot was generated.
353    /// Imports may compare timestamps to spot stale bundles.
354    pub generated_at: String,
355}
356
357/// Current `TrustBundle::v` value.
358pub const TRUST_BUNDLE_FORMAT_VERSION: u32 = 1;
359
360/// Request body for `POST /api/v1/cluster/trust-imports`.
361///
362/// The operator supplies a parsed [`TrustBundle`]. The handler proposes
363/// a Raft op so the import is replicated to every node before returning
364/// success.
365#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
366pub struct ImportTrustBundleRequest {
367    /// The bundle to import. Must be well-formed (parseable + non-empty
368    /// `cluster_domain` + valid base64 pubkey of correct length).
369    pub bundle: TrustBundle,
370    /// Optional URL the bundle was fetched from. Recorded server-side
371    /// for audit; not validated.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub source_url: Option<String>,
374}
375
376/// Response body for `POST /api/v1/cluster/trust-imports`.
377#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
378pub struct ImportTrustBundleResponse {
379    /// The `cluster_domain` of the imported bundle (echoed for clarity).
380    pub cluster_domain: String,
381    /// CA kid of the imported bundle.
382    pub ca_kid: String,
383}
384
385/// One entry in the trusted-bundle listing.
386#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
387pub struct TrustedBundleEntry {
388    /// Cluster domain.
389    pub cluster_domain: String,
390    /// CA kid.
391    pub ca_kid: String,
392    /// CA pubkey (URL-safe no-pad base64).
393    pub ca_public_key_b64: String,
394    /// RFC3339 timestamp when this bundle was originally generated by
395    /// the source cluster.
396    pub generated_at: String,
397    /// Optional source URL captured at import time.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub source_url: Option<String>,
400}
401
402/// Response body for `GET /api/v1/cluster/trust-bundles`.
403#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
404pub struct TrustedBundlesResponse {
405    /// All imported bundles, sorted by `cluster_domain` for stability.
406    pub bundles: Vec<TrustedBundleEntry>,
407}
408
409/// Request body for `POST /api/v1/cluster/revoke-token`.
410///
411/// The operator supplies EITHER the raw token (the same b64 envelope
412/// string `zlayer node generate-join-token` printed) OR the lowercase
413/// hex SHA-256 of that string. The server normalises to the hash form
414/// before proposing the Raft op so the actual token never enters
415/// replicated state. A `reason` may be attached for audit.
416#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
417pub struct RevokeTokenRequest {
418    /// Either the raw token envelope (b64 string) or its lowercase hex
419    /// SHA-256. The handler auto-detects which: 64 lowercase hex chars
420    /// is treated as a hash; anything else is hashed before insertion.
421    pub token_or_hash: String,
422    /// Optional human-readable reason recorded in the audit log
423    /// (NOT replicated — local to the leader that processed the request).
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub reason: Option<String>,
426}
427
428/// Response body for `POST /api/v1/cluster/revoke-token`.
429#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
430pub struct RevokeTokenResponse {
431    /// The canonical hash form of the revoked token (lowercase hex SHA-256).
432    pub token_hash: String,
433    /// RFC3339 timestamp when the revocation entry will be pruned. Matches
434    /// the token's own `exp` claim if the server could parse the envelope,
435    /// or `now() + 24h` as a safe fallback if only a hash was supplied.
436    pub expires_at: String,
437}
438
439/// One entry in the cluster-wide token revocation list.
440#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
441pub struct RevocationEntry {
442    /// Lowercase hex SHA-256 of the revoked token b64 envelope.
443    pub token_hash: String,
444    /// RFC3339 timestamp when this entry will be pruned.
445    pub expires_at: String,
446}
447
448/// Response body for `GET /api/v1/cluster/revocations`.
449///
450/// Returns all currently-active (un-expired) revocations replicated
451/// through Raft. Entries auto-prune at apply time; this listing is a
452/// point-in-time view of the local state machine.
453#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
454pub struct RevocationListResponse {
455    /// All currently-revoked tokens. Sorted by `expires_at` ascending so
456    /// the soonest-to-be-pruned entries come first.
457    pub revocations: Vec<RevocationEntry>,
458}
459
460/// The JWT algorithm policy a cluster enforces for join tokens.
461///
462/// Phases of the HS256 → `EdDSA` migration:
463/// - **`Hs256`**: accept HS256-JWT and Ed25519-signed-envelope tokens.
464///   EdDSA-JWT is rejected (fresh tokens have nowhere to come from
465///   in this phase).
466/// - **`Both`**: accept all three modern formats. Operators run their
467///   cluster here for a migration grace window so in-flight HS256
468///   tokens remain valid while clients re-issue under `EdDSA`.
469/// - **`Eddsa`**: accept EdDSA-JWT and Ed25519-signed-envelope.
470///   HS256-JWT is rejected with an actionable error. The symmetric
471///   `{data_dir}/join_secret` may be wiped via `WipeJoinSecret` at
472///   this point — it's no longer load-bearing.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)]
474#[serde(rename_all = "lowercase")]
475pub enum JwtAlgorithm {
476    /// HS256-only (legacy default for clusters created before Wave 11).
477    Hs256,
478    /// Both algorithms accepted (migration window).
479    #[default]
480    Both,
481    /// EdDSA-only. The cluster has decommissioned its symmetric secret.
482    Eddsa,
483}
484
485impl JwtAlgorithm {
486    /// Return the canonical lowercase identifier.
487    #[must_use]
488    pub fn as_str(self) -> &'static str {
489        match self {
490            Self::Hs256 => "hs256",
491            Self::Both => "both",
492            Self::Eddsa => "eddsa",
493        }
494    }
495}
496
497impl std::fmt::Display for JwtAlgorithm {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        f.write_str(self.as_str())
500    }
501}
502
503/// Request body for `POST /api/v1/cluster/jwt-algorithm`.
504#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
505pub struct SetJwtAlgorithmRequest {
506    /// New algorithm policy to enforce cluster-wide.
507    pub algorithm: JwtAlgorithm,
508}
509
510/// Response body for `GET /api/v1/cluster/jwt-status`.
511#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
512pub struct JwtStatusResponse {
513    /// Currently-enforced algorithm policy.
514    pub algorithm: JwtAlgorithm,
515    /// RFC3339 timestamp when this node believes `{data_dir}/join_secret`
516    /// was last wiped via `SecretsRaftOp::WipeJoinSecret`. `None` if it
517    /// has never been wiped (the file may still exist on disk for HS256).
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub join_secret_wiped_at: Option<String>,
520}
521
522/// Summary of a worker-tier worker node, returned by `GET /api/v1/cluster/workers`.
523#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
524pub struct WorkerSummary {
525    /// Worker's assigned node id.
526    pub id: u64,
527    /// Worker's API/health address (host:port).
528    pub api_addr: String,
529    /// Labels declared during Register.
530    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
531    pub labels: std::collections::HashMap<String, String>,
532    /// Worker's reported OS.
533    pub os: String,
534    /// Last time the leader observed the worker.
535    pub last_seen_unix_secs: i64,
536    /// Liveness state (`ready` | `unreachable` | `draining`).
537    pub state: String,
538}
539
540/// Snapshot of one gossip-pool peer, returned by
541/// `GET /api/v1/cluster/gossip/peers`.
542#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
543pub struct GossipPeerSummary {
544    /// Worker (or peer) node id.
545    pub node_id: u64,
546    /// `WireGuard` public key (base64-url-no-pad), if known.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub wg_pubkey: Option<String>,
549    /// `WireGuard` UDP endpoint (host:port), if known.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub wg_endpoint: Option<String>,
552    /// Overlay IP assigned to this peer, if known.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub overlay_ip: Option<String>,
555    /// Free-form labels advertised by the peer.
556    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
557    pub labels: std::collections::HashMap<String, String>,
558}
559
560/// Summary of a cluster node for listing.
561#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
562pub struct ClusterNodeSummary {
563    /// UUID or Raft-level ID
564    pub id: String,
565    /// Network address (Raft RPC address)
566    pub address: String,
567    /// Advertise address (public IP)
568    pub advertise_addr: String,
569    /// API endpoint as `advertise_addr:api_port` (e.g., "127.0.0.1:19110").
570    /// Distinct from `address` which holds the Raft RPC endpoint.
571    #[serde(default)]
572    pub api_endpoint: String,
573    /// Current status (e.g. "ready", "draining", "dead")
574    pub status: String,
575    /// Role in the Raft cluster: "leader", "voter", or "learner"
576    pub role: String,
577    /// Join mode: "full" or "replicate"
578    pub mode: String,
579    /// Whether this node is the Raft leader
580    pub is_leader: bool,
581    /// Overlay network IP assigned to this node
582    pub overlay_ip: String,
583    /// Total CPU cores on this node
584    pub cpu_total: f64,
585    /// Current CPU usage (cores)
586    pub cpu_used: f64,
587    /// Total memory in bytes
588    pub memory_total: u64,
589    /// Current memory usage in bytes
590    pub memory_used: u64,
591    /// When the node was registered (Unix timestamp ms)
592    pub registered_at: u64,
593    /// Last heartbeat timestamp (Unix timestamp ms)
594    pub last_heartbeat: u64,
595}