Skip to main content

melin_server_runtime/
admin.rs

1//! Operator admin endpoint — single TCP listener for all server-side
2//! commands an operator may send.
3//!
4//! Authentication is Ed25519 challenge-response with operator-only keys
5//! (the same handshake used for client sessions). After auth, the
6//! client sends one ASCII command terminated by `\n`:
7//!
8//! - `PROMOTE` — replica → primary leadership transition. Sets the
9//!   shared promote flag; the replica receive loop observes it and
10//!   exits with the recovered state. Available only when the spawn
11//!   caller wired a promote flag (typically the replica path).
12//! - `ROTATE` — archive the current journal segment at the next fsync
13//!   boundary and start a fresh one. Available only when the spawn
14//!   caller wired a rotation flag (any node with `--max-journal-mib >
15//!   0` or runtime rotation enabled).
16//! - `ACK-POLICY <disk|ram|disk+ram|two-disks>` — change the ack policy.
17//!   The change is journaled, in order with the events it governs, and
18//!   takes effect once the nodes the previous policy relied on hold it
19//!   (see [`crate::policy_control`]). On a primary the reply is `OK` once
20//!   the entry is published, or an `ERR` if the input ring stays full
21//!   past a bounded wait; with no replica connected, `ACK-POLICY disk` is
22//!   forced and takes effect once on this node's disk. On a replica, or a
23//!   primary still starting, the policy is staged and the reply is
24//!   `OK staged`: it is journaled when the node is promoted (or its
25//!   producer starts) — not forced, so an operator re-sends it once the
26//!   node serves if it must take effect with no replica connected. Lets an
27//!   operator resume taking writes under a weaker policy after a promotion
28//!   (no restart, no client reconnects) and restore the target policy
29//!   once replicas reattach. Available only when the spawn caller wired
30//!   the policy mailbox. `DURABILITY <local|replicated|hybrid|durably-replicated>`
31//!   is accepted as a deprecated alias for one release and logged at
32//!   `warn!`.
33//!
34//! A command for which the corresponding flag is `None` is rejected
35//! with `ERR <command> not available on this node\n` so operators get
36//! a clear diagnostic instead of a silent no-op.
37//!
38//! The listener stays alive for the lifetime of the process — multiple
39//! ROTATEs over a long run, and an eventual PROMOTE on a replica, all
40//! flow through the same socket. Concurrent or repeated triggers
41//! collapse via CAS in the journal stage / receive loop, so duplicate
42//! commands do not queue.
43
44use std::io::{BufRead, BufReader, Write};
45use std::net::{SocketAddr, TcpListener, TcpStream};
46use std::sync::Arc;
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::thread::JoinHandle;
49use std::time::Duration;
50
51use crate::ack_policy::AckPolicy;
52use crate::policy_control::{AckPolicyControl, Refused, RequestOutcome};
53use crate::promotion::PromotionRequest;
54
55use ed25519_dalek::{Verifier, VerifyingKey};
56use tracing::{debug, error, info, warn};
57
58use melin_app::auth::AuthorizedKeys;
59use melin_wire_protocol::control::TransportResponse;
60use melin_wire_protocol::control_codec;
61
62/// Spawn the admin listener on a dedicated thread.
63///
64/// The listener is bound (and set non-blocking) here, before the thread
65/// starts, so a taken or misconfigured admin port fails startup loudly —
66/// bound inside the thread it died with an `error!` nobody joins on,
67/// leaving `PROMOTE`/`ROTATE`/`ACK-POLICY` silently dead on a
68/// healthy-looking node. Returns the bound address alongside the
69/// handle (they differ from `bind_addr` when it carries port 0).
70///
71/// Any of `promote` / `rotate_requested` / `ack_policy` may be
72/// `None` to disable the corresponding command on this node. The
73/// listener still accepts connections and authenticates them — a
74/// disabled command is rejected at the command-dispatch step, not at
75/// connect time, so operator tooling sees a structured ERR rather than
76/// a TCP RST.
77pub fn spawn(
78    bind_addr: SocketAddr,
79    promote: Option<PromotionRequest>,
80    rotate_requested: Option<Arc<AtomicBool>>,
81    ack_policy: Option<Arc<AckPolicyControl>>,
82    shutdown: Arc<AtomicBool>,
83    authorized_keys: Arc<AuthorizedKeys>,
84) -> Result<(JoinHandle<()>, SocketAddr), Box<dyn std::error::Error>> {
85    let listener = TcpListener::bind(bind_addr)
86        .map_err(|e| format!("failed to bind admin listener on {bind_addr}: {e}"))?;
87    listener
88        .set_nonblocking(true)
89        .map_err(|e| format!("failed to set non-blocking on admin listener: {e}"))?;
90    let addr = listener
91        .local_addr()
92        .map_err(|e| format!("failed to read admin listener address: {e}"))?;
93    let handle = std::thread::Builder::new()
94        .name("admin-listener".into())
95        .spawn(move || {
96            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
97                run(
98                    listener,
99                    addr,
100                    promote.as_ref(),
101                    rotate_requested.as_deref(),
102                    ack_policy.as_deref(),
103                    &shutdown,
104                    &authorized_keys,
105                )
106            }));
107            if let Err(panic) = result {
108                let msg = panic_message(&panic);
109                error!(addr = %addr, panic = %msg, "admin listener thread panicked");
110            }
111        })
112        .map_err(|e| format!("failed to spawn admin listener thread: {e}"))?;
113    Ok((handle, addr))
114}
115
116fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
117    if let Some(s) = payload.downcast_ref::<&'static str>() {
118        (*s).to_string()
119    } else if let Some(s) = payload.downcast_ref::<String>() {
120        s.clone()
121    } else {
122        "<non-string panic payload>".to_string()
123    }
124}
125
126/// Accept loop over the listener pre-bound by [`spawn`]. `addr` is the
127/// actual bound address, for logs.
128fn run(
129    listener: TcpListener,
130    addr: SocketAddr,
131    promote: Option<&PromotionRequest>,
132    rotate_requested: Option<&AtomicBool>,
133    ack_policy: Option<&AckPolicyControl>,
134    shutdown: &AtomicBool,
135    authorized_keys: &AuthorizedKeys,
136) {
137    info!(
138        addr = %addr,
139        promote_enabled = promote.is_some(),
140        rotate_enabled = rotate_requested.is_some(),
141        ack_policy_enabled = ack_policy.is_some(),
142        "admin listener started"
143    );
144
145    loop {
146        if shutdown.load(Ordering::Relaxed) {
147            return;
148        }
149
150        match listener.accept() {
151            Ok((stream, peer)) => {
152                debug!(peer = %peer, "admin connection accepted");
153                handle_connection(
154                    stream,
155                    promote,
156                    rotate_requested,
157                    ack_policy,
158                    authorized_keys,
159                );
160            }
161            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
162                std::thread::sleep(Duration::from_millis(100));
163            }
164            Err(e) => {
165                debug!(error = %e, "admin listener accept error");
166                std::thread::sleep(Duration::from_millis(100));
167            }
168        }
169    }
170}
171
172/// Authenticate a connection via Ed25519 challenge-response. Operator
173/// keys only. Returns `Ok(())` on success, `Err(reason)` otherwise; the
174/// caller has already sent an `AuthFailed` response on the error path.
175fn authenticate(stream: &mut TcpStream, authorized_keys: &AuthorizedKeys) -> Result<(), String> {
176    let mut nonce = [0u8; 32];
177    getrandom::fill(&mut nonce).map_err(|e| format!("getrandom failed: {e}"))?;
178
179    let mut buf = [0u8; 128];
180    let written =
181        control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
182            .map_err(|e| format!("encode Challenge: {e}"))?;
183    stream
184        .write_all(&buf[..written])
185        .map_err(|e| format!("send Challenge: {e}"))?;
186    stream
187        .flush()
188        .map_err(|e| format!("flush Challenge: {e}"))?;
189
190    let mut len_buf = [0u8; 4];
191    std::io::Read::read_exact(stream, &mut len_buf)
192        .map_err(|e| format!("read auth frame length: {e}"))?;
193    let frame_len = u32::from_le_bytes(len_buf) as usize;
194    if frame_len > 256 {
195        send_auth_failed(stream);
196        return Err(format!("auth frame too large: {frame_len}"));
197    }
198    let mut frame_buf = [0u8; 256];
199    std::io::Read::read_exact(stream, &mut frame_buf[..frame_len])
200        .map_err(|e| format!("read auth frame payload: {e}"))?;
201
202    let cr = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
203        Ok(cr) => cr,
204        Err(e) => {
205            send_auth_failed(stream);
206            return Err(format!("decode ChallengeResponse: {e}"));
207        }
208    };
209
210    let (signature_bytes, public_key_bytes) = (cr.signature, cr.public_key);
211
212    let role = match authorized_keys.lookup(&public_key_bytes) {
213        Some(role) => role,
214        None => {
215            send_auth_failed(stream);
216            return Err("unknown public key".into());
217        }
218    };
219    if !role.client().is_some_and(|role| role.is_operator()) {
220        send_auth_failed(stream);
221        return Err(format!(
222            "admin endpoint requires an operator key, got a {} key",
223            authorized_keys.token(role)
224        ));
225    }
226
227    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|e| {
228        send_auth_failed(stream);
229        format!("invalid public key: {e}")
230    })?;
231    let signature = ed25519_dalek::Signature::from_bytes(&signature_bytes);
232    verifying_key.verify(&nonce, &signature).map_err(|e| {
233        send_auth_failed(stream);
234        format!("signature verification failed: {e}")
235    })?;
236
237    let written =
238        control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
239            .map_err(|e| format!("encode ServerReady: {e}"))?;
240    stream
241        .write_all(&buf[..written])
242        .map_err(|e| format!("send ServerReady: {e}"))?;
243    stream
244        .flush()
245        .map_err(|e| format!("flush ServerReady: {e}"))?;
246
247    Ok(())
248}
249
250fn send_auth_failed(stream: &mut TcpStream) {
251    let mut buf = [0u8; 8];
252    if let Ok(written) =
253        control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
254    {
255        // Best-effort: an unauthenticated peer may already be gone, and
256        // we're about to drop the stream regardless. Write errors here
257        // carry no actionable signal.
258        send_best_effort(stream, &buf[..written]);
259    }
260}
261
262/// Write `payload` to `stream` and flush, ignoring errors. Used for
263/// terminal admin responses where the connection is about to close: the
264/// client may already have disconnected, and there's nothing the server
265/// can usefully do with a write error after the in-process side effect
266/// (flag CAS, auth rejection) has already happened.
267fn send_best_effort(stream: &mut TcpStream, payload: &[u8]) {
268    if let Err(e) = stream.write_all(payload) {
269        debug!(error = %e, "admin write failed");
270        return;
271    }
272    if let Err(e) = stream.flush() {
273        debug!(error = %e, "admin flush failed");
274    }
275}
276
277/// Handle one authenticated admin connection. Reads a single command
278/// line and dispatches it.
279fn handle_connection(
280    mut stream: TcpStream,
281    promote: Option<&PromotionRequest>,
282    rotate_requested: Option<&AtomicBool>,
283    ack_policy: Option<&AckPolicyControl>,
284    authorized_keys: &AuthorizedKeys,
285) {
286    stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
287    stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
288
289    if let Err(reason) = authenticate(&mut stream, authorized_keys) {
290        debug!(reason = %reason, "admin auth failed");
291        return;
292    }
293
294    let cloned = match stream.try_clone() {
295        Ok(s) => s,
296        Err(e) => {
297            debug!(error = %e, "failed to clone admin stream");
298            return;
299        }
300    };
301    let mut reader = BufReader::new(cloned);
302
303    let mut line = String::new();
304    if reader.read_line(&mut line).is_err() {
305        debug!("failed to read from admin connection");
306        return;
307    }
308
309    let trimmed = line.trim();
310    match trimmed {
311        "PROMOTE" => match promote {
312            Some(request) => {
313                // First request wins; a duplicate (or a race with an
314                // auto-promotion) leaves the in-flight transition
315                // untouched — still OK from the operator's viewpoint.
316                if request.request(PromotionRequest::MANUAL) {
317                    info!("promotion triggered by operator");
318                } else {
319                    // debug!, not info!: an operator-caused no-op, not
320                    // a lifecycle event (the in-flight promotion was
321                    // already info-logged by whoever filed it).
322                    debug!("PROMOTE received but a promotion is already in flight");
323                }
324                send_best_effort(&mut stream, b"OK\n");
325            }
326            None => {
327                send_best_effort(&mut stream, b"ERR PROMOTE not available on this node\n");
328                debug!("rejected PROMOTE — flag not wired (primary node?)");
329            }
330        },
331        "ROTATE" => match rotate_requested {
332            Some(flag) => {
333                flag.store(true, Ordering::Release);
334                send_best_effort(&mut stream, b"OK\n");
335                info!("rotation requested by operator");
336            }
337            None => {
338                send_best_effort(&mut stream, b"ERR ROTATE not available on this node\n");
339                debug!("rejected ROTATE — flag not wired");
340            }
341        },
342        cmd if cmd.starts_with("ACK-POLICY") => {
343            // Parse `ACK-POLICY <policy>` with any positive whitespace
344            // between the verb and the argument. `splitn(2, ' ')` is
345            // intentional: future policies (e.g. multi-region variants)
346            // will pass additional whitespace-separated parameters
347            // through this same line and we don't want to lock in
348            // single-space framing now.
349            let mut parts = cmd.splitn(2, char::is_whitespace);
350            // Discard the verb token; we already matched on it.
351            let _ = parts.next();
352            let arg = parts.next().map(str::trim).unwrap_or("");
353            handle_ack_policy(&mut stream, ack_policy, arg);
354        }
355        cmd if cmd.starts_with("DURABILITY") => {
356            // Deprecated spelling from before 0.15, kept for one release
357            // because this command's documented use is post-failover
358            // remediation — typed from memory, under pressure, when an
359            // `ERR unknown command` is the last thing anyone needs. The
360            // old policy names are mapped too: the alias is worthless
361            // if `DURABILITY local` still fails on the argument.
362            let mut parts = cmd.splitn(2, char::is_whitespace);
363            let _ = parts.next();
364            let arg = parts.next().map(str::trim).unwrap_or("");
365            let mapped = AckPolicy::parse_legacy(arg).map(AckPolicy::as_str);
366            warn!(
367                received = %cmd,
368                "DURABILITY is deprecated and will be removed in the next minor release; use ACK-POLICY {}",
369                mapped.unwrap_or("<disk|ram|disk+ram|two-disks>")
370            );
371            handle_ack_policy(&mut stream, ack_policy, mapped.unwrap_or(arg));
372        }
373        other => {
374            debug!(received = %other, "unknown admin command");
375            send_best_effort(&mut stream, b"ERR unknown command\n");
376        }
377    }
378}
379
380/// Apply an `ACK-POLICY <policy>` command. Validates the argument, has
381/// the change journaled (or staged, see the module docs), and replies
382/// with the outcome. The journal entry is the change's audit trail; the
383/// log line says who asked. Auth is enforced upstream in
384/// [`authenticate`], so reaching this point already implies an
385/// operator-signed request.
386fn handle_ack_policy(stream: &mut TcpStream, ack_policy: Option<&AckPolicyControl>, arg: &str) {
387    let Some(control) = ack_policy else {
388        send_best_effort(stream, b"ERR ACK-POLICY not available on this node\n");
389        debug!("rejected ACK-POLICY — policy mailbox not wired");
390        return;
391    };
392    if arg.is_empty() {
393        send_best_effort(
394            stream,
395            b"ERR ACK-POLICY requires a policy (disk|ram|disk+ram|two-disks)\n",
396        );
397        debug!("rejected ACK-POLICY — missing argument");
398        return;
399    }
400    let Some(next) = AckPolicy::parse(arg) else {
401        // Build the diagnostic into a small stack buffer to avoid
402        // allocating on the admin path. The longest valid name is
403        // `two-disks` (9 chars); a 128-byte buffer covers any
404        // reasonable bad input the operator might paste.
405        let mut buf = [0u8; 128];
406        let msg = format_unknown_policy(&mut buf, arg);
407        send_best_effort(stream, msg);
408        debug!(received = %arg, "rejected ACK-POLICY — unknown policy");
409        return;
410    };
411    let outcome = control.request(next);
412    send_best_effort(stream, ack_policy_reply(outcome));
413    match outcome {
414        RequestOutcome::Published => {
415            info!(
416                policy = next.as_str(),
417                "ack policy change requested by operator"
418            );
419        }
420        RequestOutcome::Staged => {
421            info!(
422                policy = next.as_str(),
423                "ack policy staged by operator: journaled when this node serves as primary"
424            );
425        }
426        RequestOutcome::Refused(Refused::RingFull) => {
427            // A full input ring is the pipeline stalled behind its gate:
428            // degraded operation, not an operator mistake.
429            warn!(
430                policy = next.as_str(),
431                "ack policy change refused: the input ring stayed full, so it could not be journaled in time"
432            );
433        }
434        RequestOutcome::Refused(Refused::ProducerStopped) => {
435            info!(
436                policy = next.as_str(),
437                "ack policy change refused: the node stopped serving as primary (stepping down or shutting down) before journaling it"
438            );
439        }
440        RequestOutcome::Refused(Refused::AnotherWaiting) => {
441            // The admin listener serves one command at a time, so a
442            // second requester is unexpected: handled, but worth a look.
443            warn!(
444                policy = next.as_str(),
445                "ack policy change refused: another request was still waiting to be journaled"
446            );
447        }
448        RequestOutcome::Refused(Refused::NoReplicaCanAttach) => {
449            // Journaled, it would have held every reply: worth a look at
450            // whoever sent it.
451            warn!(
452                policy = next.as_str(),
453                "ack policy change refused: the policy needs a replica and this node accepts none (no --replication-bind), so it could never be met"
454            );
455        }
456    }
457}
458
459/// The admin reply to an `ACK-POLICY` request's outcome. Each refusal
460/// names its cause, since each calls for something different: retry once
461/// the pipeline drains, find the new primary, wait for the request
462/// already in flight, or pick a policy this node can meet.
463fn ack_policy_reply(outcome: RequestOutcome) -> &'static [u8] {
464    match outcome {
465        RequestOutcome::Published => b"OK\n",
466        RequestOutcome::Staged => b"OK staged\n",
467        RequestOutcome::Refused(Refused::RingFull) => {
468            b"ERR ACK-POLICY not journaled: the input ring stayed full; retry once the pipeline drains\n"
469        }
470        RequestOutcome::Refused(Refused::ProducerStopped) => {
471            b"ERR ACK-POLICY not journaled: the node stopped serving as primary (stepping down or shutting down)\n"
472        }
473        RequestOutcome::Refused(Refused::AnotherWaiting) => {
474            b"ERR ACK-POLICY not journaled: another ACK-POLICY request is still waiting\n"
475        }
476        RequestOutcome::Refused(Refused::NoReplicaCanAttach) => {
477            b"ERR ACK-POLICY not journaled: this policy needs a replica and this node accepts none (no --replication-bind), so it could never be met\n"
478        }
479    }
480}
481
482/// Format an "unknown policy" diagnostic into `buf` without allocating.
483/// Returns the populated subslice. The buffer is sized so the longest
484/// realistic operator input fits; truncation is acceptable here since
485/// the operator already knows what they typed.
486fn format_unknown_policy<'a>(buf: &'a mut [u8], arg: &str) -> &'a [u8] {
487    use std::io::Write as _;
488    let mut cursor = std::io::Cursor::new(&mut buf[..]);
489    // Best-effort write — if `arg` is pathologically long the write
490    // truncates and the operator gets a partial message, which is
491    // strictly better than allocating on the admin hot path.
492    let _ = writeln!(
493        cursor,
494        "ERR ACK-POLICY unknown policy `{arg}` (expected disk|ram|disk+ram|two-disks)"
495    );
496    let n = cursor.position() as usize;
497    &cursor.into_inner()[..n]
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use std::io::{BufRead, BufReader, Read, Write};
504
505    use ed25519_dalek::{Signer, SigningKey};
506    use melin_wire_protocol::control_codec::{
507        TAG_AUTH_FAILED, TAG_CHALLENGE, TAG_CHALLENGE_RESPONSE, TAG_SERVER_READY,
508    };
509
510    use crate::test_roles::desk_keys;
511
512    fn operator_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
513        let signing_key = SigningKey::from_bytes(&[0xAD; 32]);
514        let pub_b64 = base64::Engine::encode(
515            &base64::engine::general_purpose::STANDARD,
516            signing_key.verifying_key().to_bytes(),
517        );
518        (signing_key, Arc::new(desk_keys("operator", &pub_b64)))
519    }
520
521    /// A key under an application role: refused, like any key that is not
522    /// the operator's.
523    fn trader_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
524        let signing_key = SigningKey::from_bytes(&[0xBD; 32]);
525        let pub_b64 = base64::Engine::encode(
526            &base64::engine::general_purpose::STANDARD,
527            signing_key.verifying_key().to_bytes(),
528        );
529        (signing_key, Arc::new(desk_keys("trader", &pub_b64)))
530    }
531
532    /// Perform the transport-level auth handshake on `stream`, returning
533    /// the tag byte of the server's final response (`TAG_SERVER_READY` or
534    /// `TAG_AUTH_FAILED`). Builds frames directly from the control-codec
535    /// wire format so the test needs no application codec.
536    fn client_authenticate(stream: &mut TcpStream, key: &SigningKey) -> u8 {
537        // Read the Challenge: [len:u32][TAG_CHALLENGE][nonce:32].
538        let mut len_buf = [0u8; 4];
539        stream.read_exact(&mut len_buf).expect("read challenge len");
540        let frame_len = u32::from_le_bytes(len_buf) as usize;
541        let mut frame_buf = vec![0u8; frame_len];
542        stream
543            .read_exact(&mut frame_buf)
544            .expect("read challenge payload");
545        assert_eq!(frame_buf[0], TAG_CHALLENGE, "expected Challenge");
546        let nonce = &frame_buf[1..33];
547
548        // Reply with a ChallengeResponse:
549        // [len:u32][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32].
550        let signature = key.sign(nonce);
551        let mut frame = Vec::with_capacity(97);
552        frame.push(TAG_CHALLENGE_RESPONSE);
553        frame.extend_from_slice(&signature.to_bytes());
554        frame.extend_from_slice(&key.verifying_key().to_bytes());
555        stream
556            .write_all(&(frame.len() as u32).to_le_bytes())
557            .expect("send result len");
558        stream.write_all(&frame).expect("send");
559        stream.flush().expect("flush");
560
561        // Read the server's result frame and return its tag byte.
562        let mut len_buf2 = [0u8; 4];
563        stream.read_exact(&mut len_buf2).expect("read result len");
564        let result_len = u32::from_le_bytes(len_buf2) as usize;
565        let mut result_buf = vec![0u8; result_len];
566        stream
567            .read_exact(&mut result_buf)
568            .expect("read result payload");
569        result_buf[0]
570    }
571
572    /// Helper: connect, authenticate, send a command, return the
573    /// server's first response line.
574    fn send_command(addr: SocketAddr, key: &SigningKey, command: &[u8]) -> String {
575        let mut stream = TcpStream::connect(addr).unwrap();
576        assert_eq!(client_authenticate(&mut stream, key), TAG_SERVER_READY);
577        stream.write_all(command).unwrap();
578        stream.flush().unwrap();
579        let mut reader = BufReader::new(stream);
580        let mut line = String::new();
581        reader.read_line(&mut line).unwrap();
582        line.trim().to_string()
583    }
584
585    /// A taken admin port must fail `spawn` loudly. The old
586    /// bind-inside-the-thread pattern died with an `error!` nobody
587    /// joins on, leaving PROMOTE/ROTATE/ACK-POLICY silently dead on a
588    /// healthy-looking node.
589    #[test]
590    fn spawn_errs_when_port_taken() {
591        let (_key, auth_keys) = operator_keys();
592        let holder = TcpListener::bind("127.0.0.1:0").unwrap();
593        let addr = holder.local_addr().unwrap();
594        let shutdown = Arc::new(AtomicBool::new(false));
595        let err = spawn(addr, None, None, None, shutdown, auth_keys)
596            .expect_err("bind on a taken port must fail");
597        assert!(
598            err.to_string().contains("failed to bind admin listener"),
599            "{err}"
600        );
601    }
602
603    #[test]
604    fn promote_command_sets_flag_when_wired() {
605        let (key, auth_keys) = operator_keys();
606        let promote = PromotionRequest::new();
607        let rotate = Arc::new(AtomicBool::new(false));
608        let shutdown = Arc::new(AtomicBool::new(false));
609        let (_h, addr) = spawn(
610            "127.0.0.1:0".parse().unwrap(),
611            Some(promote.clone()),
612            Some(Arc::clone(&rotate)),
613            None,
614            Arc::clone(&shutdown),
615            auth_keys,
616        )
617        .expect("spawn admin listener");
618
619        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
620        assert_eq!(promote.pending(), Some(PromotionRequest::MANUAL));
621        assert!(!rotate.load(Ordering::Acquire));
622
623        shutdown.store(true, Ordering::Release);
624    }
625
626    #[test]
627    fn rotate_command_sets_flag_when_wired() {
628        let (key, auth_keys) = operator_keys();
629        let promote = PromotionRequest::new();
630        let rotate = Arc::new(AtomicBool::new(false));
631        let shutdown = Arc::new(AtomicBool::new(false));
632        let (_h, addr) = spawn(
633            "127.0.0.1:0".parse().unwrap(),
634            Some(promote.clone()),
635            Some(Arc::clone(&rotate)),
636            None,
637            Arc::clone(&shutdown),
638            auth_keys,
639        )
640        .expect("spawn admin listener");
641
642        assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
643        assert!(rotate.load(Ordering::Acquire));
644        assert!(!promote.is_requested());
645
646        shutdown.store(true, Ordering::Release);
647    }
648
649    /// On a primary-only node (no promote flag wired), PROMOTE returns
650    /// ERR rather than silently no-opping.
651    #[test]
652    fn promote_rejected_when_not_wired() {
653        let (key, auth_keys) = operator_keys();
654        let rotate = Arc::new(AtomicBool::new(false));
655        let shutdown = Arc::new(AtomicBool::new(false));
656        let (_h, addr) = spawn(
657            "127.0.0.1:0".parse().unwrap(),
658            None,
659            Some(Arc::clone(&rotate)),
660            None,
661            Arc::clone(&shutdown),
662            auth_keys,
663        )
664        .expect("spawn admin listener");
665
666        let resp = send_command(addr, &key, b"PROMOTE\n");
667        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
668        assert!(!rotate.load(Ordering::Acquire));
669
670        shutdown.store(true, Ordering::Release);
671    }
672
673    /// On a node without runtime rotation enabled, ROTATE returns ERR.
674    #[test]
675    fn rotate_rejected_when_not_wired() {
676        let (key, auth_keys) = operator_keys();
677        let promote = PromotionRequest::new();
678        let shutdown = Arc::new(AtomicBool::new(false));
679        let (_h, addr) = spawn(
680            "127.0.0.1:0".parse().unwrap(),
681            Some(promote.clone()),
682            None,
683            None,
684            Arc::clone(&shutdown),
685            auth_keys,
686        )
687        .expect("spawn admin listener");
688
689        let resp = send_command(addr, &key, b"ROTATE\n");
690        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
691        assert!(!promote.is_requested());
692
693        shutdown.store(true, Ordering::Release);
694    }
695
696    /// The listener stays alive across multiple commands — important
697    /// for ROTATE which an operator may issue many times over a long
698    /// run.
699    #[test]
700    fn listener_handles_multiple_commands() {
701        let (key, auth_keys) = operator_keys();
702        let promote = PromotionRequest::new();
703        let rotate = Arc::new(AtomicBool::new(false));
704        let shutdown = Arc::new(AtomicBool::new(false));
705        let (_h, addr) = spawn(
706            "127.0.0.1:0".parse().unwrap(),
707            Some(promote.clone()),
708            Some(Arc::clone(&rotate)),
709            None,
710            Arc::clone(&shutdown),
711            auth_keys,
712        )
713        .expect("spawn admin listener");
714
715        // Three rotations, each consuming the flag (simulates the
716        // journal stage's CAS).
717        for _ in 0..3 {
718            assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
719            assert!(rotate.load(Ordering::Acquire));
720            rotate
721                .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
722                .expect("flag should still be set");
723            std::thread::sleep(Duration::from_millis(100));
724        }
725
726        // Final PROMOTE on the same listener still works.
727        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
728        assert_eq!(promote.pending(), Some(PromotionRequest::MANUAL));
729
730        shutdown.store(true, Ordering::Release);
731    }
732
733    #[test]
734    fn unknown_command_rejected() {
735        let (key, auth_keys) = operator_keys();
736        let promote = PromotionRequest::new();
737        let rotate = Arc::new(AtomicBool::new(false));
738        let shutdown = Arc::new(AtomicBool::new(false));
739        let (_h, addr) = spawn(
740            "127.0.0.1:0".parse().unwrap(),
741            Some(promote.clone()),
742            Some(Arc::clone(&rotate)),
743            None,
744            Arc::clone(&shutdown),
745            auth_keys,
746        )
747        .expect("spawn admin listener");
748
749        let resp = send_command(addr, &key, b"INVALID\n");
750        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
751        assert!(!promote.is_requested());
752        assert!(!rotate.load(Ordering::Acquire));
753
754        shutdown.store(true, Ordering::Release);
755    }
756
757    #[test]
758    fn non_operator_key_rejected() {
759        let (trader_key, auth_keys) = trader_keys();
760        let promote = PromotionRequest::new();
761        let rotate = Arc::new(AtomicBool::new(false));
762        let shutdown = Arc::new(AtomicBool::new(false));
763        let (_h, addr) = spawn(
764            "127.0.0.1:0".parse().unwrap(),
765            Some(promote.clone()),
766            Some(Arc::clone(&rotate)),
767            None,
768            Arc::clone(&shutdown),
769            auth_keys,
770        )
771        .expect("spawn admin listener");
772
773        let mut stream = TcpStream::connect(addr).unwrap();
774        let result = client_authenticate(&mut stream, &trader_key);
775        assert_eq!(result, TAG_AUTH_FAILED);
776        assert!(!promote.is_requested());
777        assert!(!rotate.load(Ordering::Acquire));
778
779        shutdown.store(true, Ordering::Release);
780    }
781
782    /// Spawn an admin listener with only the ack-policy mailbox wired.
783    fn spawn_with_policy(
784        control: &Arc<AckPolicyControl>,
785    ) -> (SigningKey, SocketAddr, Arc<AtomicBool>) {
786        let (key, auth_keys) = operator_keys();
787        let shutdown = Arc::new(AtomicBool::new(false));
788        let (_h, addr) = spawn(
789            "127.0.0.1:0".parse().unwrap(),
790            None,
791            None,
792            Some(Arc::clone(control)),
793            Arc::clone(&shutdown),
794            auth_keys,
795        )
796        .expect("spawn admin listener");
797        (key, addr, shutdown)
798    }
799
800    /// Driver: spawn an admin listener whose mailbox starts at `initial`
801    /// with no producer attached (a replica, or a primary still
802    /// starting), send the supplied command, and return `(response,
803    /// policy_after)`: the policy the next promotion would request.
804    fn run_ack_policy(initial: AckPolicy, cmd: &[u8]) -> (String, AckPolicy) {
805        run_ack_policy_on(
806            crate::policy_control::test_support::replicated(initial),
807            cmd,
808        )
809    }
810
811    /// As [`run_ack_policy`], on `control`.
812    fn run_ack_policy_on(control: Arc<AckPolicyControl>, cmd: &[u8]) -> (String, AckPolicy) {
813        let (key, addr, shutdown) = spawn_with_policy(&control);
814        let resp = send_command(addr, &key, cmd);
815        let after = control.claim().policy();
816        shutdown.store(true, Ordering::Release);
817        (resp, after)
818    }
819
820    #[test]
821    fn ack_policy_command_stages_the_policy_with_no_producer() {
822        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY disk\n");
823        assert_eq!(resp, "OK staged");
824        assert_eq!(after, AckPolicy::Disk);
825    }
826
827    /// On a node that accepts no replica, a policy that needs one is
828    /// refused with the reason, and nothing is staged.
829    #[test]
830    fn ack_policy_command_refuses_a_policy_no_replica_can_meet() {
831        let standalone =
832            || AckPolicyControl::new(AckPolicy::Disk, crate::halt::Replication::Disabled);
833        let (resp, after) = run_ack_policy_on(standalone(), b"ACK-POLICY disk+ram\n");
834        assert_eq!(
835            format!("{resp}\n").as_bytes(),
836            ack_policy_reply(RequestOutcome::Refused(Refused::NoReplicaCanAttach)),
837            "{resp}"
838        );
839        assert_eq!(after, AckPolicy::Disk, "nothing staged");
840        let (resp, _) = run_ack_policy_on(standalone(), b"ACK-POLICY disk\n");
841        assert_eq!(resp, "OK staged", "`disk` is still taken");
842    }
843
844    /// On a serving primary the command replies `OK` once its producer has
845    /// journaled the change: the entry is on the input ring.
846    #[test]
847    fn ack_policy_command_is_journaled_by_the_producer() {
848        let control = crate::policy_control::test_support::replicated(AckPolicy::DiskAndRam);
849        let (mut producer, mut consumer) =
850            crate::policy_control::test_support::ring::<counter_server::CounterEvent>(8);
851        // No replica connected: a runtime `disk` is forced.
852        let replicas = crate::halt::test_support::presence(0);
853        let halt = crate::halt::test_support::gate_over(Some(replicas));
854        let mut publisher = crate::policy_control::test_support::booted(&control).attach(
855            &mut producer,
856            &halt,
857            None,
858        );
859        let (key, addr, shutdown) = spawn_with_policy(&control);
860        let polling = Arc::new(AtomicBool::new(true));
861        let poller = {
862            let polling = Arc::clone(&polling);
863            std::thread::spawn(move || {
864                while polling.load(Ordering::Relaxed) {
865                    publisher.poll(&mut producer, &halt);
866                    std::thread::yield_now();
867                }
868            })
869        };
870
871        assert_eq!(send_command(addr, &key, b"ACK-POLICY disk\n"), "OK");
872        polling.store(false, Ordering::Relaxed);
873        poller.join().expect("the producer panicked");
874        let (_, slot) = consumer.try_consume().expect("the entry is on the ring");
875        let melin_journal::JournalEvent::AckPolicyChange(change) = slot.event else {
876            panic!("not a policy change");
877        };
878        assert_eq!(
879            change,
880            melin_journal::AckPolicyChange {
881                policy: AckPolicy::Disk.to_byte(),
882                unwitnessed: true,
883            },
884            "a runtime `disk` with no replica connected is forced"
885        );
886        shutdown.store(true, Ordering::Release);
887    }
888
889    /// A producer that never publishes (its ring full) gets the command
890    /// refused once the bounded wait is out, and nothing changes.
891    #[test]
892    fn ack_policy_command_refused_when_it_cannot_be_journaled() {
893        use crate::policy_control::test_support::{attach, control_with_publish_wait, ring};
894        let control = control_with_publish_wait(AckPolicy::DiskAndRam, Duration::from_millis(50));
895        let (mut producer, _consumer) = ring::<counter_server::CounterEvent>(8);
896        let _publisher = attach(&control, &mut producer);
897        let (key, addr, shutdown) = spawn_with_policy(&control);
898        let resp = send_command(addr, &key, b"ACK-POLICY disk\n");
899        assert_eq!(
900            format!("{resp}\n").as_bytes(),
901            ack_policy_reply(RequestOutcome::Refused(Refused::RingFull)),
902            "{resp}"
903        );
904        assert_eq!(control.requested(), AckPolicy::DiskAndRam);
905        shutdown.store(true, Ordering::Release);
906    }
907
908    /// Each outcome has its own reply, and each refusal names its cause.
909    #[test]
910    fn the_ack_policy_reply_names_each_outcome() {
911        let reply = |outcome| std::str::from_utf8(ack_policy_reply(outcome)).expect("ASCII");
912        assert_eq!(reply(RequestOutcome::Published), "OK\n");
913        assert_eq!(reply(RequestOutcome::Staged), "OK staged\n");
914        for (cause, names) in [
915            (Refused::RingFull, "the input ring stayed full"),
916            (
917                Refused::ProducerStopped,
918                "stopped serving as primary (stepping down or shutting down)",
919            ),
920            (Refused::AnotherWaiting, "another ACK-POLICY request"),
921            (
922                Refused::NoReplicaCanAttach,
923                "needs a replica and this node accepts none (no --replication-bind), so it could never be met",
924            ),
925        ] {
926            let text = reply(RequestOutcome::Refused(cause));
927            assert!(
928                text.starts_with("ERR ACK-POLICY not journaled: ") && text.contains(names),
929                "{cause:?}: {text}"
930            );
931            assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
932        }
933    }
934
935    #[test]
936    fn deprecated_durability_alias_maps_old_names() {
937        // `DURABILITY local` from muscle memory must still work, and
938        // land on the policy the old name meant.
939        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"DURABILITY local\n");
940        assert_eq!(resp, "OK staged");
941        assert_eq!(after, AckPolicy::Disk);
942
943        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY durably-replicated\n");
944        assert_eq!(resp, "OK staged");
945        assert_eq!(after, AckPolicy::TwoDisks);
946
947        // The alias also takes the new names, and still rejects junk.
948        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY ram\n");
949        assert_eq!(resp, "OK staged");
950        assert_eq!(after, AckPolicy::Ram);
951        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY fast\n");
952        assert!(resp.starts_with("ERR ACK-POLICY unknown policy"), "{resp}");
953        assert_eq!(after, AckPolicy::Disk);
954    }
955
956    #[test]
957    fn ack_policy_command_accepts_each_policy() {
958        for target in [
959            AckPolicy::Disk,
960            AckPolicy::Ram,
961            AckPolicy::DiskAndRam,
962            AckPolicy::TwoDisks,
963        ] {
964            let cmd = format!("ACK-POLICY {}\n", target.as_str());
965            let (resp, after) = run_ack_policy(AckPolicy::Disk, cmd.as_bytes());
966            assert_eq!(resp, "OK staged", "policy {target}");
967            assert_eq!(after, target);
968        }
969    }
970
971    #[test]
972    fn ack_policy_command_rejects_unknown_policy() {
973        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY fast\n");
974        assert!(
975            resp.starts_with("ERR ACK-POLICY unknown policy"),
976            "expected unknown-policy ERR, got {resp}"
977        );
978        // The requested policy must NOT have been clobbered on a bad command.
979        assert_eq!(after, AckPolicy::DiskAndRam);
980    }
981
982    #[test]
983    fn ack_policy_command_rejects_missing_argument() {
984        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY\n");
985        assert!(
986            resp.starts_with("ERR ACK-POLICY requires a policy"),
987            "expected missing-arg ERR, got {resp}"
988        );
989        assert_eq!(after, AckPolicy::DiskAndRam);
990    }
991
992    #[test]
993    fn ack_policy_command_rejected_when_not_wired() {
994        // On a pure-replica node (no response stage), ACK-POLICY must
995        // not silently no-op — operators get a structured ERR.
996        let (key, auth_keys) = operator_keys();
997        let promote = PromotionRequest::new();
998        let shutdown = Arc::new(AtomicBool::new(false));
999        let (_h, addr) = spawn(
1000            "127.0.0.1:0".parse().unwrap(),
1001            Some(promote.clone()),
1002            None,
1003            None,
1004            Arc::clone(&shutdown),
1005            auth_keys,
1006        )
1007        .expect("spawn admin listener");
1008
1009        let resp = send_command(addr, &key, b"ACK-POLICY disk\n");
1010        assert!(
1011            resp.starts_with("ERR ACK-POLICY not available"),
1012            "expected not-available ERR, got {resp}"
1013        );
1014
1015        shutdown.store(true, Ordering::Release);
1016    }
1017}