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_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, through the wire protocol's one parser of
538        // it, which also checks the listener speaks this build's client
539        // protocol.
540        let mut len_buf = [0u8; 4];
541        stream.read_exact(&mut len_buf).expect("read challenge len");
542        let frame_len = u32::from_le_bytes(len_buf) as usize;
543        let mut frame_buf = vec![0u8; frame_len];
544        stream
545            .read_exact(&mut frame_buf)
546            .expect("read challenge payload");
547        let nonce = melin_wire_protocol::control_codec::decode_challenge(&frame_buf)
548            .expect("expected Challenge");
549
550        // Reply with a ChallengeResponse:
551        // [len:u32][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32].
552        let signature = key.sign(&nonce);
553        let mut frame = Vec::with_capacity(97);
554        frame.push(TAG_CHALLENGE_RESPONSE);
555        frame.extend_from_slice(&signature.to_bytes());
556        frame.extend_from_slice(&key.verifying_key().to_bytes());
557        stream
558            .write_all(&(frame.len() as u32).to_le_bytes())
559            .expect("send result len");
560        stream.write_all(&frame).expect("send");
561        stream.flush().expect("flush");
562
563        // Read the server's result frame and return its tag byte.
564        let mut len_buf2 = [0u8; 4];
565        stream.read_exact(&mut len_buf2).expect("read result len");
566        let result_len = u32::from_le_bytes(len_buf2) as usize;
567        let mut result_buf = vec![0u8; result_len];
568        stream
569            .read_exact(&mut result_buf)
570            .expect("read result payload");
571        result_buf[0]
572    }
573
574    /// Helper: connect, authenticate, send a command, return the
575    /// server's first response line.
576    fn send_command(addr: SocketAddr, key: &SigningKey, command: &[u8]) -> String {
577        let mut stream = TcpStream::connect(addr).unwrap();
578        assert_eq!(client_authenticate(&mut stream, key), TAG_SERVER_READY);
579        stream.write_all(command).unwrap();
580        stream.flush().unwrap();
581        let mut reader = BufReader::new(stream);
582        let mut line = String::new();
583        reader.read_line(&mut line).unwrap();
584        line.trim().to_string()
585    }
586
587    /// A taken admin port must fail `spawn` loudly. The old
588    /// bind-inside-the-thread pattern died with an `error!` nobody
589    /// joins on, leaving PROMOTE/ROTATE/ACK-POLICY silently dead on a
590    /// healthy-looking node.
591    #[test]
592    fn spawn_errs_when_port_taken() {
593        let (_key, auth_keys) = operator_keys();
594        let holder = TcpListener::bind("127.0.0.1:0").unwrap();
595        let addr = holder.local_addr().unwrap();
596        let shutdown = Arc::new(AtomicBool::new(false));
597        let err = spawn(addr, None, None, None, shutdown, auth_keys)
598            .expect_err("bind on a taken port must fail");
599        assert!(
600            err.to_string().contains("failed to bind admin listener"),
601            "{err}"
602        );
603    }
604
605    #[test]
606    fn promote_command_sets_flag_when_wired() {
607        let (key, auth_keys) = operator_keys();
608        let promote = PromotionRequest::new();
609        let rotate = Arc::new(AtomicBool::new(false));
610        let shutdown = Arc::new(AtomicBool::new(false));
611        let (_h, addr) = spawn(
612            "127.0.0.1:0".parse().unwrap(),
613            Some(promote.clone()),
614            Some(Arc::clone(&rotate)),
615            None,
616            Arc::clone(&shutdown),
617            auth_keys,
618        )
619        .expect("spawn admin listener");
620
621        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
622        assert_eq!(promote.pending(), Some(PromotionRequest::MANUAL));
623        assert!(!rotate.load(Ordering::Acquire));
624
625        shutdown.store(true, Ordering::Release);
626    }
627
628    #[test]
629    fn rotate_command_sets_flag_when_wired() {
630        let (key, auth_keys) = operator_keys();
631        let promote = PromotionRequest::new();
632        let rotate = Arc::new(AtomicBool::new(false));
633        let shutdown = Arc::new(AtomicBool::new(false));
634        let (_h, addr) = spawn(
635            "127.0.0.1:0".parse().unwrap(),
636            Some(promote.clone()),
637            Some(Arc::clone(&rotate)),
638            None,
639            Arc::clone(&shutdown),
640            auth_keys,
641        )
642        .expect("spawn admin listener");
643
644        assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
645        assert!(rotate.load(Ordering::Acquire));
646        assert!(!promote.is_requested());
647
648        shutdown.store(true, Ordering::Release);
649    }
650
651    /// On a primary-only node (no promote flag wired), PROMOTE returns
652    /// ERR rather than silently no-opping.
653    #[test]
654    fn promote_rejected_when_not_wired() {
655        let (key, auth_keys) = operator_keys();
656        let rotate = Arc::new(AtomicBool::new(false));
657        let shutdown = Arc::new(AtomicBool::new(false));
658        let (_h, addr) = spawn(
659            "127.0.0.1:0".parse().unwrap(),
660            None,
661            Some(Arc::clone(&rotate)),
662            None,
663            Arc::clone(&shutdown),
664            auth_keys,
665        )
666        .expect("spawn admin listener");
667
668        let resp = send_command(addr, &key, b"PROMOTE\n");
669        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
670        assert!(!rotate.load(Ordering::Acquire));
671
672        shutdown.store(true, Ordering::Release);
673    }
674
675    /// On a node without runtime rotation enabled, ROTATE returns ERR.
676    #[test]
677    fn rotate_rejected_when_not_wired() {
678        let (key, auth_keys) = operator_keys();
679        let promote = PromotionRequest::new();
680        let shutdown = Arc::new(AtomicBool::new(false));
681        let (_h, addr) = spawn(
682            "127.0.0.1:0".parse().unwrap(),
683            Some(promote.clone()),
684            None,
685            None,
686            Arc::clone(&shutdown),
687            auth_keys,
688        )
689        .expect("spawn admin listener");
690
691        let resp = send_command(addr, &key, b"ROTATE\n");
692        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
693        assert!(!promote.is_requested());
694
695        shutdown.store(true, Ordering::Release);
696    }
697
698    /// The listener stays alive across multiple commands — important
699    /// for ROTATE which an operator may issue many times over a long
700    /// run.
701    #[test]
702    fn listener_handles_multiple_commands() {
703        let (key, auth_keys) = operator_keys();
704        let promote = PromotionRequest::new();
705        let rotate = Arc::new(AtomicBool::new(false));
706        let shutdown = Arc::new(AtomicBool::new(false));
707        let (_h, addr) = spawn(
708            "127.0.0.1:0".parse().unwrap(),
709            Some(promote.clone()),
710            Some(Arc::clone(&rotate)),
711            None,
712            Arc::clone(&shutdown),
713            auth_keys,
714        )
715        .expect("spawn admin listener");
716
717        // Three rotations, each consuming the flag (simulates the
718        // journal stage's CAS).
719        for _ in 0..3 {
720            assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
721            assert!(rotate.load(Ordering::Acquire));
722            rotate
723                .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
724                .expect("flag should still be set");
725            std::thread::sleep(Duration::from_millis(100));
726        }
727
728        // Final PROMOTE on the same listener still works.
729        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
730        assert_eq!(promote.pending(), Some(PromotionRequest::MANUAL));
731
732        shutdown.store(true, Ordering::Release);
733    }
734
735    #[test]
736    fn unknown_command_rejected() {
737        let (key, auth_keys) = operator_keys();
738        let promote = PromotionRequest::new();
739        let rotate = Arc::new(AtomicBool::new(false));
740        let shutdown = Arc::new(AtomicBool::new(false));
741        let (_h, addr) = spawn(
742            "127.0.0.1:0".parse().unwrap(),
743            Some(promote.clone()),
744            Some(Arc::clone(&rotate)),
745            None,
746            Arc::clone(&shutdown),
747            auth_keys,
748        )
749        .expect("spawn admin listener");
750
751        let resp = send_command(addr, &key, b"INVALID\n");
752        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
753        assert!(!promote.is_requested());
754        assert!(!rotate.load(Ordering::Acquire));
755
756        shutdown.store(true, Ordering::Release);
757    }
758
759    #[test]
760    fn non_operator_key_rejected() {
761        let (trader_key, auth_keys) = trader_keys();
762        let promote = PromotionRequest::new();
763        let rotate = Arc::new(AtomicBool::new(false));
764        let shutdown = Arc::new(AtomicBool::new(false));
765        let (_h, addr) = spawn(
766            "127.0.0.1:0".parse().unwrap(),
767            Some(promote.clone()),
768            Some(Arc::clone(&rotate)),
769            None,
770            Arc::clone(&shutdown),
771            auth_keys,
772        )
773        .expect("spawn admin listener");
774
775        let mut stream = TcpStream::connect(addr).unwrap();
776        let result = client_authenticate(&mut stream, &trader_key);
777        assert_eq!(result, TAG_AUTH_FAILED);
778        assert!(!promote.is_requested());
779        assert!(!rotate.load(Ordering::Acquire));
780
781        shutdown.store(true, Ordering::Release);
782    }
783
784    /// Spawn an admin listener with only the ack-policy mailbox wired.
785    fn spawn_with_policy(
786        control: &Arc<AckPolicyControl>,
787    ) -> (SigningKey, SocketAddr, Arc<AtomicBool>) {
788        let (key, auth_keys) = operator_keys();
789        let shutdown = Arc::new(AtomicBool::new(false));
790        let (_h, addr) = spawn(
791            "127.0.0.1:0".parse().unwrap(),
792            None,
793            None,
794            Some(Arc::clone(control)),
795            Arc::clone(&shutdown),
796            auth_keys,
797        )
798        .expect("spawn admin listener");
799        (key, addr, shutdown)
800    }
801
802    /// Driver: spawn an admin listener whose mailbox starts at `initial`
803    /// with no producer attached (a replica, or a primary still
804    /// starting), send the supplied command, and return `(response,
805    /// policy_after)`: the policy the next promotion would request.
806    fn run_ack_policy(initial: AckPolicy, cmd: &[u8]) -> (String, AckPolicy) {
807        run_ack_policy_on(
808            crate::policy_control::test_support::replicated(initial),
809            cmd,
810        )
811    }
812
813    /// As [`run_ack_policy`], on `control`.
814    fn run_ack_policy_on(control: Arc<AckPolicyControl>, cmd: &[u8]) -> (String, AckPolicy) {
815        let (key, addr, shutdown) = spawn_with_policy(&control);
816        let resp = send_command(addr, &key, cmd);
817        let after = control.claim().policy();
818        shutdown.store(true, Ordering::Release);
819        (resp, after)
820    }
821
822    #[test]
823    fn ack_policy_command_stages_the_policy_with_no_producer() {
824        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY disk\n");
825        assert_eq!(resp, "OK staged");
826        assert_eq!(after, AckPolicy::Disk);
827    }
828
829    /// On a node that accepts no replica, a policy that needs one is
830    /// refused with the reason, and nothing is staged.
831    #[test]
832    fn ack_policy_command_refuses_a_policy_no_replica_can_meet() {
833        let standalone =
834            || AckPolicyControl::new(AckPolicy::Disk, crate::halt::Replication::Disabled);
835        let (resp, after) = run_ack_policy_on(standalone(), b"ACK-POLICY disk+ram\n");
836        assert_eq!(
837            format!("{resp}\n").as_bytes(),
838            ack_policy_reply(RequestOutcome::Refused(Refused::NoReplicaCanAttach)),
839            "{resp}"
840        );
841        assert_eq!(after, AckPolicy::Disk, "nothing staged");
842        let (resp, _) = run_ack_policy_on(standalone(), b"ACK-POLICY disk\n");
843        assert_eq!(resp, "OK staged", "`disk` is still taken");
844    }
845
846    /// On a serving primary the command replies `OK` once its producer has
847    /// journaled the change: the entry is on the input ring.
848    #[test]
849    fn ack_policy_command_is_journaled_by_the_producer() {
850        let control = crate::policy_control::test_support::replicated(AckPolicy::DiskAndRam);
851        let (mut producer, mut consumer) =
852            crate::policy_control::test_support::ring::<counter_server::CounterEvent>(8);
853        // No replica connected: a runtime `disk` is forced.
854        let replicas = crate::halt::test_support::presence(0);
855        let halt = crate::halt::test_support::gate_over(Some(replicas));
856        let mut publisher = crate::policy_control::test_support::booted(&control).attach(
857            &mut producer,
858            &halt,
859            None,
860        );
861        let (key, addr, shutdown) = spawn_with_policy(&control);
862        let polling = Arc::new(AtomicBool::new(true));
863        let poller = {
864            let polling = Arc::clone(&polling);
865            std::thread::spawn(move || {
866                while polling.load(Ordering::Relaxed) {
867                    publisher.poll(&mut producer, &halt);
868                    std::thread::yield_now();
869                }
870            })
871        };
872
873        assert_eq!(send_command(addr, &key, b"ACK-POLICY disk\n"), "OK");
874        polling.store(false, Ordering::Relaxed);
875        poller.join().expect("the producer panicked");
876        let (_, slot) = consumer.try_consume().expect("the entry is on the ring");
877        let melin_journal::JournalEvent::AckPolicyChange(change) = slot.event else {
878            panic!("not a policy change");
879        };
880        assert_eq!(
881            change,
882            melin_journal::AckPolicyChange {
883                policy: AckPolicy::Disk.to_byte(),
884                unwitnessed: true,
885            },
886            "a runtime `disk` with no replica connected is forced"
887        );
888        shutdown.store(true, Ordering::Release);
889    }
890
891    /// A producer that never publishes (its ring full) gets the command
892    /// refused once the bounded wait is out, and nothing changes.
893    #[test]
894    fn ack_policy_command_refused_when_it_cannot_be_journaled() {
895        use crate::policy_control::test_support::{attach, control_with_publish_wait, ring};
896        let control = control_with_publish_wait(AckPolicy::DiskAndRam, Duration::from_millis(50));
897        let (mut producer, _consumer) = ring::<counter_server::CounterEvent>(8);
898        let _publisher = attach(&control, &mut producer);
899        let (key, addr, shutdown) = spawn_with_policy(&control);
900        let resp = send_command(addr, &key, b"ACK-POLICY disk\n");
901        assert_eq!(
902            format!("{resp}\n").as_bytes(),
903            ack_policy_reply(RequestOutcome::Refused(Refused::RingFull)),
904            "{resp}"
905        );
906        assert_eq!(control.requested(), AckPolicy::DiskAndRam);
907        shutdown.store(true, Ordering::Release);
908    }
909
910    /// Each outcome has its own reply, and each refusal names its cause.
911    #[test]
912    fn the_ack_policy_reply_names_each_outcome() {
913        let reply = |outcome| std::str::from_utf8(ack_policy_reply(outcome)).expect("ASCII");
914        assert_eq!(reply(RequestOutcome::Published), "OK\n");
915        assert_eq!(reply(RequestOutcome::Staged), "OK staged\n");
916        for (cause, names) in [
917            (Refused::RingFull, "the input ring stayed full"),
918            (
919                Refused::ProducerStopped,
920                "stopped serving as primary (stepping down or shutting down)",
921            ),
922            (Refused::AnotherWaiting, "another ACK-POLICY request"),
923            (
924                Refused::NoReplicaCanAttach,
925                "needs a replica and this node accepts none (no --replication-bind), so it could never be met",
926            ),
927        ] {
928            let text = reply(RequestOutcome::Refused(cause));
929            assert!(
930                text.starts_with("ERR ACK-POLICY not journaled: ") && text.contains(names),
931                "{cause:?}: {text}"
932            );
933            assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
934        }
935    }
936
937    #[test]
938    fn deprecated_durability_alias_maps_old_names() {
939        // `DURABILITY local` from muscle memory must still work, and
940        // land on the policy the old name meant.
941        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"DURABILITY local\n");
942        assert_eq!(resp, "OK staged");
943        assert_eq!(after, AckPolicy::Disk);
944
945        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY durably-replicated\n");
946        assert_eq!(resp, "OK staged");
947        assert_eq!(after, AckPolicy::TwoDisks);
948
949        // The alias also takes the new names, and still rejects junk.
950        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY ram\n");
951        assert_eq!(resp, "OK staged");
952        assert_eq!(after, AckPolicy::Ram);
953        let (resp, after) = run_ack_policy(AckPolicy::Disk, b"DURABILITY fast\n");
954        assert!(resp.starts_with("ERR ACK-POLICY unknown policy"), "{resp}");
955        assert_eq!(after, AckPolicy::Disk);
956    }
957
958    #[test]
959    fn ack_policy_command_accepts_each_policy() {
960        for target in [
961            AckPolicy::Disk,
962            AckPolicy::Ram,
963            AckPolicy::DiskAndRam,
964            AckPolicy::TwoDisks,
965        ] {
966            let cmd = format!("ACK-POLICY {}\n", target.as_str());
967            let (resp, after) = run_ack_policy(AckPolicy::Disk, cmd.as_bytes());
968            assert_eq!(resp, "OK staged", "policy {target}");
969            assert_eq!(after, target);
970        }
971    }
972
973    #[test]
974    fn ack_policy_command_rejects_unknown_policy() {
975        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY fast\n");
976        assert!(
977            resp.starts_with("ERR ACK-POLICY unknown policy"),
978            "expected unknown-policy ERR, got {resp}"
979        );
980        // The requested policy must NOT have been clobbered on a bad command.
981        assert_eq!(after, AckPolicy::DiskAndRam);
982    }
983
984    #[test]
985    fn ack_policy_command_rejects_missing_argument() {
986        let (resp, after) = run_ack_policy(AckPolicy::DiskAndRam, b"ACK-POLICY\n");
987        assert!(
988            resp.starts_with("ERR ACK-POLICY requires a policy"),
989            "expected missing-arg ERR, got {resp}"
990        );
991        assert_eq!(after, AckPolicy::DiskAndRam);
992    }
993
994    #[test]
995    fn ack_policy_command_rejected_when_not_wired() {
996        // On a pure-replica node (no response stage), ACK-POLICY must
997        // not silently no-op — operators get a structured ERR.
998        let (key, auth_keys) = operator_keys();
999        let promote = PromotionRequest::new();
1000        let shutdown = Arc::new(AtomicBool::new(false));
1001        let (_h, addr) = spawn(
1002            "127.0.0.1:0".parse().unwrap(),
1003            Some(promote.clone()),
1004            None,
1005            None,
1006            Arc::clone(&shutdown),
1007            auth_keys,
1008        )
1009        .expect("spawn admin listener");
1010
1011        let resp = send_command(addr, &key, b"ACK-POLICY disk\n");
1012        assert!(
1013            resp.starts_with("ERR ACK-POLICY not available"),
1014            "expected not-available ERR, got {resp}"
1015        );
1016
1017        shutdown.store(true, Ordering::Release);
1018    }
1019}