Skip to main content

krabka_raft/controller/
submit.rs

1//! Record submission: the leader-local `submit_change`, the resolution of a
2//! leader's controller-listener address, and the encode → dial → translate path
3//! a follower forwards a rejected batch over. The transport seam that dials the
4//! leader is isolated here so the encode and translate halves stay testable
5//! without a live quorum.
6
7use {
8    super::{ControllerHandle, connection_options},
9    crate::{
10        DelegationTokenMutation, SubmitChangeResult,
11        error::RaftError,
12        network::OutboundDialer,
13        types::{NodeId, controller_endpoint_addr as endpoint_addr_from_endpoints},
14        wire::{
15            API_KEY_DELEGATION_TOKEN_MUTATION, API_KEY_SUBMIT_CHANGE, KrabkaSubmitChangeRequest,
16            KrabkaSubmitChangeResponse, PRIVATE_CLUSTER_AUTHORIZATION_FAILED,
17            PRIVATE_UNSUPPORTED_VERSION, SUBMIT_CHANGE_UNCOMMITTED_TAIL, private_request_version,
18        },
19    },
20    krabka_metadata::{MetadataImage, MetadataRecord},
21    std::sync::Arc,
22};
23
24impl ControllerHandle {
25    /// Submit a mutation admitted against this controller's published image.
26    /// The engine checks that image before appending; this method never forwards
27    /// a mutation whose admission happened on a different controller.
28    ///
29    /// # Errors
30    /// Returns a leadership, stale-image, uncommitted-tail, or submission error.
31    pub async fn submit_change_if_image(
32        &self,
33        records: Vec<MetadataRecord>,
34        expected_image: Arc<MetadataImage>,
35    ) -> Result<SubmitChangeResult, RaftError> {
36        self.engine
37            .submit_change_if_image(records, expected_image)
38            .await
39    }
40
41    /// Submit a batch of metadata records. Returns `Ok(())` once committed AND
42    /// applied on the leader. Pre-validation lives in the engine. On a follower
43    /// (`NotLeader` with a known leader), forwards directly to the leader's
44    /// controller listener via `API_KEY_SUBMIT_CHANGE`.
45    ///
46    /// # Errors
47    /// Returns an error if validation, replication, or forwarding fails.
48    #[tracing::instrument(
49        level = "debug",
50        skip_all,
51        fields(node = self.self_node_id.0, records = records.len())
52    )]
53    pub async fn submit_change(
54        &self,
55        records: Vec<MetadataRecord>,
56    ) -> Result<SubmitChangeResult, RaftError> {
57        let submission = self.engine.submit_change(records.clone()).await;
58        match submission {
59            Err(RaftError::NotLeader {
60                current_leader: Some(leader),
61            }) => match self.voter_addr(leader) {
62                Some(addr) => self.forward_submit_to(leader, &addr, &records).await,
63                None => Err(RaftError::NotLeader {
64                    current_leader: Some(leader),
65                }),
66            },
67            other => other,
68        }
69    }
70
71    /// Submit token mutations whose expected records are checked by the leader
72    /// in the same engine turn as the append.
73    ///
74    /// # Errors
75    ///
76    /// Returns a leadership, transport, protocol, or mutation-rejection error.
77    pub async fn submit_delegation_token_mutations(
78        &self,
79        mutations: Vec<DelegationTokenMutation>,
80    ) -> Result<SubmitChangeResult, RaftError> {
81        match self
82            .engine
83            .submit_delegation_token_mutations(mutations.clone())
84            .await
85        {
86            Ok(result) => Ok(result),
87            Err(RaftError::NotLeader {
88                current_leader: Some(leader),
89            }) => {
90                if let Some(addr) = self.voter_addr(leader) {
91                    self.forward_delegation_token_mutations_to(leader, &addr, &mutations)
92                        .await
93                } else {
94                    Err(RaftError::NotLeader {
95                        current_leader: Some(leader),
96                    })
97                }
98            }
99            Err(error) => Err(error),
100        }
101    }
102
103    // cargo-mutants: an I/O-only wrapper with no in-process signal. It encodes the
104    // already-validated mutations, dials the leader through
105    // `krabka_client_core::Connection` -- which no test in this process can build --
106    // and returns the leader's reply. The body it sends is produced by
107    // `encode_delegation_token_mutation_body`, which is mutation-tested.
108    #[cfg_attr(test, mutants::skip)]
109    async fn forward_delegation_token_mutations_to(
110        &self,
111        leader: NodeId,
112        addr: &str,
113        mutations: &[DelegationTokenMutation],
114    ) -> Result<SubmitChangeResult, RaftError> {
115        let body = encode_delegation_token_mutation_body(mutations)?;
116        let version = private_request_version(
117            krabka_metadata::PrivateRpc::DelegationTokenMutation,
118            Some(&self.current_image()),
119        );
120        let options = connection_options(
121            &self.client_id,
122            self.client_dispatch_queue_capacity,
123            self.client_frame_max,
124        );
125        let connection = self
126            .dialer
127            .dial(leader, addr, options)
128            .await
129            .map_err(RaftError::Network)?;
130        let response = connection
131            .raw_request(
132                API_KEY_DELEGATION_TOKEN_MUTATION,
133                version,
134                bytes::Bytes::from(body),
135            )
136            .await
137            .map_err(RaftError::Network)?;
138        connection.close();
139        translate_submit_change_response(
140            &response,
141            leader,
142            API_KEY_DELEGATION_TOKEN_MUTATION,
143            version,
144        )
145    }
146
147    /// Forward a raw Kafka-wire request for `api_key`/`version` to the
148    /// current quorum leader's controller listener and return its raw
149    /// response bytes, unmodified.
150    ///
151    /// Used by the broker's `DescribeQuorum` handler: Kafka's `KafkaApis`
152    /// forwards `DescribeQuorum` from the broker listener to the active
153    /// controller unconditionally (`forwardToController`), so a combined or
154    /// controller node that is not itself the leader must reach the leader
155    /// the same way a broker-only observer already does.
156    ///
157    /// # Errors
158    /// `RaftError::NotLeader` when no leader is known or the leader's
159    /// address cannot be resolved, `RaftError::Network` when the dial or
160    /// round trip fails.
161    // cargo-mutants: an I/O-only wrapper with no in-process signal (dial + one
162    // raw request + close, over a `krabka_client_core::Connection` that no
163    // test in this process can build). Covered by a live two-node cluster
164    // test below.
165    #[cfg_attr(test, mutants::skip)]
166    pub async fn forward_raw(
167        &self,
168        api_key: i16,
169        version: i16,
170        body: bytes::Bytes,
171    ) -> Result<bytes::Bytes, RaftError> {
172        let Some(leader) = self.engine.quorum_snapshot().leader_id else {
173            return Err(RaftError::NotLeader {
174                current_leader: None,
175            });
176        };
177        let Some(addr) = self.voter_addr(leader) else {
178            return Err(RaftError::NotLeader {
179                current_leader: Some(leader),
180            });
181        };
182        let opts = connection_options(
183            &self.client_id,
184            self.client_dispatch_queue_capacity,
185            self.client_frame_max,
186        );
187        let conn = self
188            .dialer
189            .dial(leader, &addr, opts)
190            .await
191            .map_err(RaftError::Network)?;
192        let resp = conn
193            .raw_request(api_key, version, body)
194            .await
195            .map_err(RaftError::Network)?;
196        conn.close();
197        Ok(resp)
198    }
199
200    /// Resolve a voter's controller listener `<host>:<port>` from the static
201    /// voter set's CONTROLLER endpoint. See [`controller_endpoint_addr`].
202    fn voter_addr(&self, node_id: NodeId) -> Option<String> {
203        let voters = self.engine.quorum_snapshot().voters;
204        controller_endpoint_addr(&voters, node_id)
205            .or_else(|| controller_endpoint_addr(&self.voters, node_id))
206    }
207
208    /// Open a one-shot authenticated connection to the leader's controller
209    /// listener, send a wincode-encoded `Vec<MetadataRecord>` as
210    /// `API_KEY_SUBMIT_CHANGE`, and translate the response into a `RaftError`.
211    ///
212    /// Decomposed into three killable steps: [`encode_submit_change_body`] builds
213    /// the exact wire bytes, the [`SubmitChangeTransport`] seam performs the
214    /// (un-mockable) dial→`raw_request`→close round trip and hands back the raw
215    /// response body, and [`translate_submit_change_response`] decodes that body
216    /// and maps the transport `error_code` into a `RaftError`.
217    // cargo-mutants: thin wrapper; builds the live DialerSubmitTransport, needs a real dialer
218    #[cfg_attr(test, mutants::skip)]
219    #[tracing::instrument(
220        level = "debug",
221        skip_all,
222        fields(node = self.self_node_id.0, leader = leader.0, addr, records = records.len()),
223        err
224    )]
225    async fn forward_submit_to(
226        &self,
227        leader: NodeId,
228        addr: &str,
229        records: &[krabka_metadata::MetadataRecord],
230    ) -> Result<SubmitChangeResult, RaftError> {
231        let transport = DialerSubmitTransport {
232            dialer: self.dialer.as_ref(),
233            client_id: &self.client_id,
234            client_dispatch_queue_capacity: self.client_dispatch_queue_capacity,
235            client_frame_max: self.client_frame_max,
236        };
237        let version = private_request_version(
238            krabka_metadata::PrivateRpc::SubmitChange,
239            Some(&self.current_image()),
240        );
241        forward_submit_via(&transport, leader, addr, version, records).await
242    }
243}
244
245/// Resolve a voter's CONTROLLER-listener `<host>:<port>` from the voter set,
246/// preferring the endpoint named `CONTROLLER` and falling back to the first.
247///
248/// The host is returned VERBATIM (a DNS name), never pre-resolved to a
249/// `SocketAddr`. The dialer re-resolves it per connect (`TcpStream::connect`),
250/// so a peer that restarts on a new pod IP stays reachable. Parsing to a
251/// `SocketAddr` here would (a) freeze a restarted peer's boot-time IP and
252/// (b) fail outright on a non-literal hostname — which silently disabled
253/// leader-forwarding of `submit_change` (e.g. broker self-registration), since
254/// `parse()` returned `None` and the forward was skipped.
255fn controller_endpoint_addr(voters: &krabka_metadata::VoterSet, node_id: NodeId) -> Option<String> {
256    let voter = voters.get(node_id)?;
257    endpoint_addr_from_endpoints(&voter.endpoints)
258}
259
260/// The single un-mockable step of leader-forwarding a `submit_change`: dial the
261/// leader's controller listener, issue one `API_KEY_SUBMIT_CHANGE` request with
262/// the already-encoded `body`, and return the raw response body bytes. The
263/// concrete [`krabka_client_core::Connection`] is opaque (it cannot be built in
264/// a test), so this seam returns plain `Bytes` — every serialize/translate
265/// decision around it stays unit-testable against a mock.
266#[cfg_attr(test, mockall::automock)]
267#[async_trait::async_trait]
268trait SubmitChangeTransport: Send + Sync {
269    /// Round-trip the encoded `API_KEY_SUBMIT_CHANGE` `body` to the leader at
270    /// request `version` and return the raw response body.
271    async fn send_submit_change(
272        &self,
273        leader: NodeId,
274        addr: &str,
275        version: i16,
276        body: Vec<u8>,
277    ) -> Result<bytes::Bytes, krabka_client_core::ClientError>;
278}
279
280/// Live [`SubmitChangeTransport`] over the injected [`OutboundDialer`]: dials a
281/// one-shot authenticated connection, sends the request at the negotiated
282/// `API_KEY_SUBMIT_CHANGE` version, closes the connection, and returns the response body. This is the
283/// only part of the forward path that touches a real socket.
284struct DialerSubmitTransport<'a> {
285    dialer: &'a dyn OutboundDialer,
286    client_id: &'a str,
287    client_dispatch_queue_capacity: krabka_client_core::ConnectionDispatchQueueCapacity,
288    client_frame_max: krabka_client_core::ClientFrameMax,
289}
290
291#[async_trait::async_trait]
292impl SubmitChangeTransport for DialerSubmitTransport<'_> {
293    // cargo-mutants: the only un-mockable step: dial + one `API_KEY_SUBMIT_CHANGE` + close, with
294    // no offline signal (a `krabka_client_core::Connection` cannot be built in a
295    // test). `#[mutants::skip]` rather than an `exclude_re` because cargo-mutants'
296    // name-regex exclusions do not reliably match the struct-field-deletion mutant
297    // its outbound connection options would otherwise generate.
298    #[cfg_attr(test, mutants::skip)]
299    async fn send_submit_change(
300        &self,
301        leader: NodeId,
302        addr: &str,
303        version: i16,
304        body: Vec<u8>,
305    ) -> Result<bytes::Bytes, krabka_client_core::ClientError> {
306        let opts = connection_options(
307            self.client_id,
308            self.client_dispatch_queue_capacity,
309            self.client_frame_max,
310        );
311        let conn = self.dialer.dial(leader, addr, opts).await?;
312        let resp_body = conn
313            .raw_request(API_KEY_SUBMIT_CHANGE, version, bytes::Bytes::from(body))
314            .await?;
315        conn.close();
316        Ok(resp_body)
317    }
318}
319
320/// `forward_submit_to`'s testable core: serialize → send (via the injected
321/// [`SubmitChangeTransport`]) → translate. The real path supplies a
322/// [`DialerSubmitTransport`]; tests supply a mock so the serialize/translate
323/// decisions carry mutation signal without a live quorum.
324async fn forward_submit_via(
325    transport: &dyn SubmitChangeTransport,
326    leader: NodeId,
327    addr: &str,
328    version: i16,
329    records: &[krabka_metadata::MetadataRecord],
330) -> Result<SubmitChangeResult, RaftError> {
331    let body = encode_submit_change_body(records)?;
332    let resp_body = transport
333        .send_submit_change(leader, addr, version, body)
334        .await
335        .map_err(RaftError::Network)?;
336    translate_submit_change_response(&resp_body, leader, API_KEY_SUBMIT_CHANGE, version)
337}
338
339/// Build the exact `API_KEY_SUBMIT_CHANGE` v0 request body for `records`:
340/// wincode-encode the `Vec<MetadataRecord>`, then frame it with the
341/// length-prefixed [`crate::wire::KrabkaSubmitChangeRequest`] codec. Kept
342/// byte-for-byte identical to the inlined path so the wire stays exact.
343fn encode_submit_change_body(
344    records: &[krabka_metadata::MetadataRecord],
345) -> Result<Vec<u8>, RaftError> {
346    let body_bytes = <serde_wincode::SerdeCompat<Vec<krabka_metadata::MetadataRecord>> as wincode::Serialize>::serialize(
347        &records.to_vec(),
348    )
349    .map_err(RaftError::from)?;
350    submit_change_frame(body_bytes)
351}
352
353fn encode_delegation_token_mutation_body(
354    mutations: &[DelegationTokenMutation],
355) -> Result<Vec<u8>, RaftError> {
356    let payload = <serde_wincode::SerdeCompat<Vec<DelegationTokenMutation>> as wincode::Serialize>::serialize(
357        &mutations.to_vec(),
358    )
359    .map_err(RaftError::from)?;
360    submit_change_frame(payload)
361}
362
363fn submit_change_frame(payload: Vec<u8>) -> Result<Vec<u8>, RaftError> {
364    let request = KrabkaSubmitChangeRequest {
365        records: bytes::Bytes::from(payload),
366    };
367    let mut body = Vec::with_capacity(request.records.len() + 4);
368    request.encode_v0(&mut body)?;
369    Ok(body)
370}
371
372/// Decode a `KrabkaSubmitChangeResponse` from the leader's `resp_body` and map
373/// its transport `error_code` into the caller's `Result`:
374/// - `0` → applied (`Ok`).
375/// - `2` → the leader rejected at apply-time (topic already exists). The wire
376///   carries only a code; the topic name is what the caller had in hand.
377/// - [`crate::wire::SUBMIT_CHANGE_UNCOMMITTED_TAIL`] → the leader refused a
378///   compare-and-set until its tail commits ([`RaftError::UncommittedTail`]).
379/// - [`crate::wire::PRIVATE_CLUSTER_AUTHORIZATION_FAILED`] → the leader denied
380///   `ClusterAction` to this node's principal
381///   ([`RaftError::ClusterAuthorizationFailed`]).
382/// - [`crate::wire::PRIVATE_UNSUPPORTED_VERSION`] → the leader does not
383///   implement `version`, the version of `api_key` this node sent
384///   ([`RaftError::UnsupportedPrivateVersion`]). `krabka.version` negotiation
385///   keeps a sender at a version every registered node serves, so only a peer
386///   outside that agreement answers it.
387/// - anything else → collapse to `NotLeader` (`CreateTopics` maps that to the
388///   retryable `NOT_CONTROLLER`), preferring the response's `leader_hint` when
389///   non-negative and falling back to the dialed `leader`.
390fn translate_submit_change_response(
391    resp_body: &[u8],
392    leader: NodeId,
393    api_key: i16,
394    version: i16,
395) -> Result<SubmitChangeResult, RaftError> {
396    let mut cur: &[u8] = resp_body;
397    let resp = KrabkaSubmitChangeResponse::decode_v0(&mut cur)?;
398    match resp.error_code {
399        0 => <serde_wincode::SerdeCompat<SubmitChangeResult> as wincode::Deserialize>::deserialize(
400            &resp.result,
401        )
402        .map_err(RaftError::from),
403        2 => Err(RaftError::Metadata(
404            krabka_metadata::MetadataError::TopicExists(String::new()),
405        )),
406        SUBMIT_CHANGE_UNCOMMITTED_TAIL => Err(RaftError::UncommittedTail),
407        PRIVATE_CLUSTER_AUTHORIZATION_FAILED => Err(RaftError::ClusterAuthorizationFailed),
408        PRIVATE_UNSUPPORTED_VERSION => {
409            Err(RaftError::UnsupportedPrivateVersion { api_key, version })
410        }
411        _ => Err(RaftError::NotLeader {
412            current_leader: (resp.leader_hint >= 0)
413                .then(|| NodeId(u64::try_from(resp.leader_hint).unwrap_or(leader.0))),
414        }),
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use {
421        super::*,
422        crate::{
423            DelegationTokenMutation, SubmitChangeResult,
424            controller::test_support::{
425                bootstrap_controller, joining_controller, submit_change_response_bytes,
426                topic_record,
427            },
428            wire::{
429                API_KEY_DELEGATION_TOKEN_MUTATION, API_KEY_METADATA_FETCH, API_KEY_SUBMIT_CHANGE,
430                KrabkaSubmitChangeRequest, PRIVATE_CLUSTER_AUTHORIZATION_FAILED,
431                PRIVATE_UNSUPPORTED_VERSION, SUBMIT_CHANGE_UNCOMMITTED_TAIL,
432            },
433        },
434        assert2::check,
435        std::io::{Error, ErrorKind},
436        uuid::Uuid,
437    };
438
439    #[derive(Clone, Copy)]
440    enum SubmitResponseExpectation {
441        Applied,
442        TopicExists,
443        UncommittedTail,
444        ClusterAuthorizationFailed,
445        UnsupportedVersion,
446        NotLeaderWithHint,
447        NotLeaderWithoutHint,
448    }
449
450    #[yare::parameterized(
451        registered_voter = {
452            NodeId(2),
453            Some("demo-broker-2-0.demo-broker-headless.default.svc.cluster.local:9093".to_owned()),
454        },
455        unknown_voter = { NodeId(99), None },
456    )]
457    fn controller_endpoint_addr_keeps_dns_hostname_not_parsed_socketaddr(
458        node_id: NodeId,
459        expected: Option<String>,
460    ) {
461        // Regression: a voter endpoint host is a per-pod DNS FQDN, NOT a
462        // pre-resolved IP. The resolver must return "<host>:<port>" verbatim so
463        // the dialer re-resolves it per connect. Parsing to a `SocketAddr`
464        // returns None for a hostname, which silently disabled leader-forwarding
465        // of `submit_change` — broker self-registration then failed with "not
466        // leader" and RF=3 topics could not be placed.
467        let host = "demo-broker-2-0.demo-broker-headless.default.svc.cluster.local";
468        let voters = krabka_metadata::VoterSet::from_voters([krabka_metadata::Voter {
469            id: NodeId(2),
470            directory_id: Uuid::nil(),
471            endpoints: vec![krabka_metadata::VoterEndpoint {
472                name: "CONTROLLER".to_owned(),
473                host: host.to_owned(),
474                port: 9093,
475            }],
476            kraft_version: krabka_metadata::KRaftVersionRange::default(),
477        }]);
478        assert2::assert!(controller_endpoint_addr(&voters, node_id) == expected);
479    }
480
481    #[test]
482    fn controller_endpoint_addr_prefers_controller_endpoint_over_others() {
483        // A voter advertises several listeners. The resolver must pick the one
484        // named CONTROLLER even when it is not first in the list — submit_change
485        // must be forwarded to the controller listener, not (e.g.) the
486        // inter-broker REPLICATION listener on a different port. The non-
487        // CONTROLLER endpoint is placed FIRST so a flipped `name == "CONTROLLER"`
488        // predicate (matching the first NON-controller endpoint instead) returns
489        // the wrong address.
490        let voters = krabka_metadata::VoterSet::from_voters([krabka_metadata::Voter {
491            id: NodeId(7),
492            directory_id: Uuid::nil(),
493            endpoints: vec![
494                krabka_metadata::VoterEndpoint {
495                    name: "REPLICATION".to_owned(),
496                    host: "replication-host".to_owned(),
497                    port: 9092,
498                },
499                krabka_metadata::VoterEndpoint {
500                    name: "CONTROLLER".to_owned(),
501                    host: "controller-host".to_owned(),
502                    port: 9093,
503                },
504            ],
505            kraft_version: krabka_metadata::KRaftVersionRange::default(),
506        }]);
507        assert2::assert!(
508            controller_endpoint_addr(&voters, NodeId(7)) == Some("controller-host:9093".to_owned())
509        );
510    }
511
512    #[test]
513    fn encode_submit_change_body_frames_wincode_records_with_i32_length_prefix() {
514        // The forward path must produce the exact `KrabkaSubmitChangeRequest` v0
515        // wire bytes: a 4-byte big-endian length prefix followed by the
516        // wincode-encoded `Vec<MetadataRecord>`. Decoding the framed body back
517        // and re-deserializing must round-trip to the original records, proving
518        // the prefix length matches the payload length (a mutated length or a
519        // dropped wincode step fails to decode or yields different records).
520        let records = vec![topic_record("alpha"), topic_record("beta")];
521        let body = encode_submit_change_body(&records).expect("encode");
522
523        let expected_wincode = <serde_wincode::SerdeCompat<
524            Vec<krabka_metadata::MetadataRecord>,
525        > as wincode::Serialize>::serialize(&records)
526        .expect("wincode");
527        assert2::assert!(body.len() == expected_wincode.len() + 4);
528
529        let mut cur: &[u8] = &body;
530        let req = KrabkaSubmitChangeRequest::decode_v0(&mut cur).expect("decode frame");
531        assert2::assert!(req.records.as_ref() == expected_wincode.as_slice());
532        // The framed payload IS the wincode encoding of the original records, so
533        // it deserializes back to them — proving no double-framing / corruption.
534        let decoded = <serde_wincode::SerdeCompat<
535            Vec<krabka_metadata::MetadataRecord>,
536        > as wincode::Deserialize>::deserialize(&req.records)
537        .expect("wincode decode");
538        assert2::assert!(decoded == records);
539    }
540
541    /// A `SubmitChange` v0 request body that carries one `TopicRecord`.
542    const SUBMIT_CHANGE_V0_GOLDEN: &[u8] = &[
543        0, 0, 0, 51, // frame length
544        1, 0, 0, 0, 0, 0, 0, 0, // one record
545        0, 0, 0, 0, // V1Topic
546        1, 0, 0, 0, 0, 0, 0, 0, b't', // name
547        16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // topic id
548        1, 0, 0, 0, // partitions
549        1, 0, // replication factor
550    ];
551
552    /// A `DelegationTokenMutation` v0 request body that carries one `Delete`.
553    fn delegation_token_mutation_v0_golden() -> Vec<u8> {
554        // Captured wincode principal, shared by owner, requester and renewer.
555        // These chunks are literal oracle bytes, independent of serialization.
556        const USER_ALICE: &[u8] = &[
557            4, 0, 0, 0, 0, 0, 0, 0, b'U', b's', b'e', b'r', 5, 0, 0, 0, 0, 0, 0, 0, b'a', b'l',
558            b'i', b'c', b'e',
559        ];
560        let prefix: &[u8] = &[
561            0, 0, 0, 130, // frame length
562            1, 0, 0, 0, 0, 0, 0, 0, // one mutation
563            2, 0, 0, 0, // Delete
564            3, 0, 0, 0, 0, 0, 0, 0, b't', b'o', b'k', // token id
565        ];
566        let timestamps_and_renewer_count: &[u8] = &[
567            1, 0, 0, 0, 0, 0, 0, 0, // issue timestamp
568            2, 0, 0, 0, 0, 0, 0, 0, // expiry timestamp
569            3, 0, 0, 0, 0, 0, 0, 0, // max timestamp
570            1, 0, 0, 0, 0, 0, 0, 0, // one renewer
571        ];
572        [
573            prefix,
574            USER_ALICE,
575            USER_ALICE,
576            timestamps_and_renewer_count,
577            USER_ALICE,
578        ]
579        .concat()
580    }
581
582    /// The wincode `SubmitChangeResult` of a `SubmitChange` v0 response with
583    /// one offset reservation.
584    const SUBMIT_CHANGE_RESULT_V0_GOLDEN: &[u8] = &[
585        1, 0, 0, 0, 0, 0, 0, 0, // one reservation
586        1, 0, 0, 0, 0, 0, 0, 0, b't', // topic
587        0, 0, 0, 0, // partition
588        5, 0, 0, 0, 0, 0, 0, 0, // base offset
589        2, 0, 0, 0, 0, 0, 0, 0, // count
590        3, 0, 0, 0, 0, 0, 0, 0, // leader epoch
591    ];
592
593    /// One delegation token, the representative value of the
594    /// `DelegationTokenMutation` golden body.
595    fn golden_token() -> krabka_metadata::DelegationTokenRecord {
596        let alice = krabka_security::KafkaPrincipal {
597            principal_type: "User".into(),
598            name: "alice".into(),
599        };
600        krabka_metadata::DelegationTokenRecord {
601            token_id: "tok".into(),
602            owner: alice.clone(),
603            requester: alice.clone(),
604            issue_timestamp_ms: 1,
605            expiry_timestamp_ms: 2,
606            max_timestamp_ms: 3,
607            renewers: vec![alice],
608        }
609    }
610
611    /// The bodies of `SubmitChange` and `DelegationTokenMutation` v0 requests and
612    /// of a `SubmitChange` v0 result, byte for byte: the length-prefixed frame
613    /// around the wincode payload. Nodes of two 1.x builds exchange these
614    /// during a rolling upgrade, so a change to one of them is a change to the
615    /// 1.x contract and takes a new api version.
616    #[test]
617    fn v0_payloads_match_their_golden_bytes() {
618        let delegation_golden = delegation_token_mutation_v0_golden();
619        let records = vec![krabka_metadata::MetadataRecord::V1Topic(
620            krabka_metadata::TopicRecord {
621                name: "t".into(),
622                topic_id: Uuid::from_u128(1),
623                partitions: 1,
624                replication_factor: 1,
625            },
626        )];
627        let mutations = vec![crate::DelegationTokenMutation::Delete {
628            expected: golden_token(),
629        }];
630        let result = SubmitChangeResult {
631            offset_reservations: vec![crate::OffsetReservation {
632                topic: "t".into(),
633                partition: 0,
634                base_offset: 5,
635                count: 2,
636                leader_epoch: 3,
637            }],
638        };
639        let result_bytes =
640            <serde_wincode::SerdeCompat<SubmitChangeResult> as wincode::Serialize>::serialize(
641                &result,
642            )
643            .expect("wincode");
644
645        check!(encode_submit_change_body(&records).unwrap() == SUBMIT_CHANGE_V0_GOLDEN);
646        check!(encode_delegation_token_mutation_body(&mutations).unwrap() == delegation_golden);
647        check!(result_bytes == SUBMIT_CHANGE_RESULT_V0_GOLDEN);
648
649        // Each golden body decodes back to the value it was made from.
650        let mut cur: &[u8] = SUBMIT_CHANGE_V0_GOLDEN;
651        let frame = KrabkaSubmitChangeRequest::decode_v0(&mut cur).unwrap();
652        check!(
653            <serde_wincode::SerdeCompat<Vec<krabka_metadata::MetadataRecord>> as wincode::Deserialize>::deserialize(&frame.records).unwrap()
654                == records
655        );
656        let mut cur: &[u8] = &delegation_golden;
657        let frame = KrabkaSubmitChangeRequest::decode_v0(&mut cur).unwrap();
658        check!(
659            <serde_wincode::SerdeCompat<Vec<crate::DelegationTokenMutation>> as wincode::Deserialize>::deserialize(&frame.records).unwrap()
660                == mutations
661        );
662        check!(
663            <serde_wincode::SerdeCompat<crate::SubmitChangeResult> as wincode::Deserialize>::deserialize(SUBMIT_CHANGE_RESULT_V0_GOLDEN).unwrap()
664                == result
665        );
666    }
667
668    #[yare::parameterized(
669        applied = { 0, -1, API_KEY_SUBMIT_CHANGE, 0, SubmitResponseExpectation::Applied },
670        topic_exists = { 2, -1, API_KEY_SUBMIT_CHANGE, 0, SubmitResponseExpectation::TopicExists },
671        uncommitted_tail = {
672            SUBMIT_CHANGE_UNCOMMITTED_TAIL,
673            -1,
674            API_KEY_SUBMIT_CHANGE,
675            0,
676            SubmitResponseExpectation::UncommittedTail,
677        },
678        cluster_authorization = {
679            PRIVATE_CLUSTER_AUTHORIZATION_FAILED,
680            -1,
681            API_KEY_SUBMIT_CHANGE,
682            0,
683            SubmitResponseExpectation::ClusterAuthorizationFailed,
684        },
685        submit_unsupported_version = {
686            PRIVATE_UNSUPPORTED_VERSION,
687            -1,
688            API_KEY_SUBMIT_CHANGE,
689            2,
690            SubmitResponseExpectation::UnsupportedVersion,
691        },
692        token_unsupported_version = {
693            PRIVATE_UNSUPPORTED_VERSION,
694            -1,
695            API_KEY_DELEGATION_TOKEN_MUTATION,
696            2,
697            SubmitResponseExpectation::UnsupportedVersion,
698        },
699        not_leader_with_hint = {
700            1,
701            9,
702            API_KEY_SUBMIT_CHANGE,
703            0,
704            SubmitResponseExpectation::NotLeaderWithHint,
705        },
706        not_leader_without_hint = {
707            3,
708            -1,
709            API_KEY_SUBMIT_CHANGE,
710            0,
711            SubmitResponseExpectation::NotLeaderWithoutHint,
712        },
713    )]
714    fn translate_submit_change_response_maps_each_error_code(
715        code: i16,
716        leader_hint: i64,
717        api_key: i16,
718        version: i16,
719        expected: SubmitResponseExpectation,
720    ) {
721        let result = translate_submit_change_response(
722            &submit_change_response_bytes(code, leader_hint),
723            NodeId(5),
724            api_key,
725            version,
726        );
727        match expected {
728            SubmitResponseExpectation::Applied => assert2::assert!(result.is_ok()),
729            expected => {
730                let error = result.expect_err("response is an error");
731                let matches = match expected {
732                    SubmitResponseExpectation::Applied => unreachable!(),
733                    SubmitResponseExpectation::TopicExists => matches!(
734                        error,
735                        RaftError::Metadata(krabka_metadata::MetadataError::TopicExists(_))
736                    ),
737                    SubmitResponseExpectation::UncommittedTail => {
738                        matches!(error, RaftError::UncommittedTail)
739                    }
740                    SubmitResponseExpectation::ClusterAuthorizationFailed => {
741                        matches!(error, RaftError::ClusterAuthorizationFailed)
742                    }
743                    SubmitResponseExpectation::UnsupportedVersion => matches!(
744                        error,
745                        RaftError::UnsupportedPrivateVersion {
746                            api_key: got,
747                            version: got_version
748                        } if got == api_key && got_version == version
749                    ),
750                    SubmitResponseExpectation::NotLeaderWithHint => {
751                        matches!(error, RaftError::NotLeader {
752                            current_leader: Some(NodeId(9))
753                        })
754                    }
755                    SubmitResponseExpectation::NotLeaderWithoutHint => {
756                        matches!(error, RaftError::NotLeader {
757                            current_leader: None
758                        })
759                    }
760                };
761                assert2::assert!(matches);
762            }
763        }
764    }
765
766    #[test]
767    fn translate_submit_change_response_propagates_decode_error() {
768        // A truncated body (fewer than the fixed 10 response bytes) must surface
769        // as a protocol error rather than being silently treated as success.
770        let err = translate_submit_change_response(&[0u8; 3], NodeId(5), API_KEY_SUBMIT_CHANGE, 0)
771            .expect_err("truncated decodes err");
772        assert2::assert!(matches!(err, RaftError::Protocol(_)));
773    }
774
775    #[tokio::test]
776    async fn forward_submit_via_sends_encoded_body_and_returns_ok_on_applied() {
777        // End-to-end of the testable core: the transport must receive the exact
778        // framed body for `records` (the wincode + length-prefix encoding) at the
779        // dialed leader/addr, and an `error_code = 0` response yields Ok.
780        let records = vec![topic_record("gamma")];
781        let expected_body = encode_submit_change_body(&records).expect("encode");
782
783        let mut transport = MockSubmitChangeTransport::new();
784        transport
785            .expect_send_submit_change()
786            .withf(move |leader, addr, version, body| {
787                *leader == 7
788                    && addr == "leader-host:9093"
789                    && *version == 0
790                    && body == &expected_body
791            })
792            .times(1)
793            .returning(|_, _, _, _| Ok(submit_change_response_bytes(0, -1)));
794
795        forward_submit_via(&transport, NodeId(7), "leader-host:9093", 0, &records)
796            .await
797            .expect("applied");
798    }
799
800    #[tokio::test]
801    async fn forward_submit_via_translates_not_leader_hint() {
802        // The transport's response leader_hint must flow through translation to
803        // the caller's NotLeader error.
804        let mut transport = MockSubmitChangeTransport::new();
805        transport
806            .expect_send_submit_change()
807            .returning(|_, _, _, _| Ok(submit_change_response_bytes(1, 4)));
808
809        let err = forward_submit_via(&transport, NodeId(7), "leader-host:9093", 0, &[
810            topic_record("z"),
811        ])
812        .await
813        .expect_err("not leader");
814        assert2::assert!(matches!(err, RaftError::NotLeader {
815            current_leader: Some(NodeId(4))
816        }));
817    }
818
819    #[tokio::test]
820    async fn forward_submit_via_maps_transport_error_to_network() {
821        // A dial/send failure surfaces as RaftError::Network (so CreateTopics
822        // retries), not a panic or a swallowed success.
823        let mut transport = MockSubmitChangeTransport::new();
824        transport
825            .expect_send_submit_change()
826            .returning(|_, _, _, _| {
827                Err(krabka_client_core::ClientError::Io(Error::new(
828                    ErrorKind::ConnectionRefused,
829                    "refused",
830                )))
831            });
832
833        let err = forward_submit_via(&transport, NodeId(7), "leader-host:9093", 0, &[
834            topic_record("z"),
835        ])
836        .await
837        .expect_err("network error");
838        assert2::assert!(matches!(err, RaftError::Network(_)));
839    }
840
841    #[test]
842    fn encode_delegation_token_mutation_body_frames_mutations() {
843        use {krabka_metadata::DelegationTokenRecord, krabka_security::KafkaPrincipal};
844
845        let rec = DelegationTokenRecord {
846            token_id: "tok".into(),
847            owner: KafkaPrincipal {
848                principal_type: "User".into(),
849                name: "alice".into(),
850            },
851            requester: KafkaPrincipal {
852                principal_type: "User".into(),
853                name: "alice".into(),
854            },
855            issue_timestamp_ms: 10,
856            max_timestamp_ms: 20,
857            expiry_timestamp_ms: 15,
858            renewers: vec![],
859        };
860        let mutations = vec![crate::DelegationTokenMutation::Delete { expected: rec }];
861        let body = encode_delegation_token_mutation_body(&mutations).expect("encode");
862        assert2::assert!(body.len() > 10);
863        let mut cur: &[u8] = &body;
864        let req = KrabkaSubmitChangeRequest::decode_v0(&mut cur).expect("decode frame");
865        let decoded = <serde_wincode::SerdeCompat<
866            Vec<DelegationTokenMutation>,
867        > as wincode::Deserialize>::deserialize(&req.records)
868        .expect("wincode decode");
869        assert2::assert!(decoded == mutations);
870    }
871
872    #[tokio::test]
873    async fn controller_handle_voter_addr_resolves_known_voter() {
874        let (_dir, ctrl) = bootstrap_controller("start").await;
875        let addr = ctrl.voter_addr(NodeId(1));
876        assert2::assert!(addr == Some("127.0.0.1:0".to_owned()));
877        assert2::assert!(ctrl.voter_addr(NodeId(999)).is_none());
878        ctrl.shutdown().await;
879    }
880
881    #[tokio::test]
882    async fn forward_raw_with_no_known_leader_rejects_not_leader() {
883        let (_dir, ctrl) = joining_controller("join start").await;
884
885        let err = ctrl
886            .forward_raw(API_KEY_METADATA_FETCH, 0, bytes::Bytes::new())
887            .await
888            .expect_err("no leader is known yet");
889        assert2::assert!(matches!(err, RaftError::NotLeader {
890            current_leader: None
891        }));
892        ctrl.shutdown().await;
893    }
894
895    #[tokio::test]
896    async fn submit_delegation_token_mutations_on_join_node_rejects_not_leader() {
897        let (_dir, ctrl) = joining_controller("join start").await;
898        let res = ctrl.submit_delegation_token_mutations(vec![]).await;
899        assert2::assert!(matches!(
900            res,
901            Err(RaftError::NotLeader {
902                current_leader: None
903            })
904        ));
905        ctrl.shutdown().await;
906    }
907}