1use std::{collections::BTreeMap, slice};
15
16use krabka_protocol::owned::{
17 alter_client_quotas_request::{
18 AlterClientQuotasRequest, EntityData as AlterEntity, EntryData as AlterEntry,
19 OpData as AlterOp,
20 },
21 alter_client_quotas_response::AlterClientQuotasResponse,
22 describe_client_quotas_request::{ComponentData, DescribeClientQuotasRequest},
23 describe_client_quotas_response::DescribeClientQuotasResponse,
24};
25
26use crate::{
27 AdminClient, AdminError, KafkaError, kafka_error_if, kafka_error_name, retry::ControllerRetry,
28};
29
30pub const ENTITY_USER: &str = "user";
32pub const ENTITY_CLIENT_ID: &str = "client-id";
34pub const ENTITY_IP: &str = "ip";
36
37const MATCH_TYPE_EXACT: i8 = 0;
39const MATCH_TYPE_DEFAULT: i8 = 1;
40const MATCH_TYPE_SPECIFIED: i8 = 2;
41
42const UNKNOWN_SERVER_ERROR: i16 = -1;
44
45#[derive(Debug, Clone, PartialEq)]
47pub enum QuotaOp {
48 Set { key: String, value: f64 },
51 Remove { key: String },
54}
55
56pub type UserQuotaConfig = BTreeMap<String, f64>;
59
60pub type ClientQuotaEntity = BTreeMap<String, Option<String>>;
63
64pub type ClientQuotas = BTreeMap<ClientQuotaEntity, BTreeMap<String, f64>>;
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum ClientQuotaMatch {
71 Exact(String),
73 Default,
75 Specified,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ClientQuotaFilterComponent {
83 pub entity_type: String,
85 pub matches: ClientQuotaMatch,
87}
88
89impl ClientQuotaFilterComponent {
90 #[must_use]
92 pub fn of_entity(entity_type: impl Into<String>, name: impl Into<String>) -> Self {
93 Self {
94 entity_type: entity_type.into(),
95 matches: ClientQuotaMatch::Exact(name.into()),
96 }
97 }
98
99 #[must_use]
101 pub fn of_default_entity(entity_type: impl Into<String>) -> Self {
102 Self {
103 entity_type: entity_type.into(),
104 matches: ClientQuotaMatch::Default,
105 }
106 }
107
108 #[must_use]
110 pub fn of_entity_type(entity_type: impl Into<String>) -> Self {
111 Self {
112 entity_type: entity_type.into(),
113 matches: ClientQuotaMatch::Specified,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
120pub struct ClientQuotaFilter {
121 #[builder(default)]
123 pub components: Vec<ClientQuotaFilterComponent>,
124 #[builder(default)]
127 pub strict: bool,
128}
129
130impl ClientQuotaFilter {
131 #[must_use]
134 pub const fn contains(components: Vec<ClientQuotaFilterComponent>) -> Self {
135 Self {
136 components,
137 strict: false,
138 }
139 }
140
141 #[must_use]
144 pub const fn contains_only(components: Vec<ClientQuotaFilterComponent>) -> Self {
145 Self {
146 components,
147 strict: true,
148 }
149 }
150
151 #[must_use]
153 pub const fn all() -> Self {
154 Self::contains(Vec::new())
155 }
156}
157
158#[derive(Debug, Clone, PartialEq)]
160pub struct ClientQuotaAlteration {
161 pub entity: ClientQuotaEntity,
163 pub ops: Vec<QuotaOp>,
165}
166
167fn describe_request(filter: &ClientQuotaFilter) -> DescribeClientQuotasRequest {
170 DescribeClientQuotasRequest {
171 components: filter
172 .components
173 .iter()
174 .map(|component| {
175 let (match_type, match_) = match &component.matches {
176 ClientQuotaMatch::Exact(name) => (MATCH_TYPE_EXACT, Some(name.clone())),
177 ClientQuotaMatch::Default => (MATCH_TYPE_DEFAULT, None),
178 ClientQuotaMatch::Specified => (MATCH_TYPE_SPECIFIED, None),
179 };
180 ComponentData {
181 entity_type: component.entity_type.clone(),
182 match_type,
183 match_,
184 ..Default::default()
185 }
186 })
187 .collect(),
188 strict: filter.strict,
189 ..Default::default()
190 }
191}
192
193fn describe_result(response: DescribeClientQuotasResponse) -> Result<ClientQuotas, AdminError> {
197 if response.error_code != 0 {
198 return Err(AdminError::Broker {
199 api: "DescribeClientQuotas",
200 code: response.error_code,
201 name: kafka_error_name(response.error_code),
202 message: response.error_message,
203 });
204 }
205 Ok(response
206 .entries
207 .unwrap_or_default()
208 .into_iter()
209 .map(|entry| {
210 let entity = entry
211 .entity
212 .into_iter()
213 .map(|entity| (entity.entity_type, entity.entity_name))
214 .collect();
215 let values = entry
216 .values
217 .into_iter()
218 .map(|value| (value.key, value.value))
219 .collect();
220 (entity, values)
221 })
222 .collect())
223}
224
225fn alter_request(
228 alterations: &[ClientQuotaAlteration],
229 validate_only: bool,
230) -> AlterClientQuotasRequest {
231 AlterClientQuotasRequest {
232 entries: alterations
233 .iter()
234 .map(|alteration| AlterEntry {
235 entity: alteration
236 .entity
237 .iter()
238 .map(|(entity_type, entity_name)| AlterEntity {
239 entity_type: entity_type.clone(),
240 entity_name: entity_name.clone(),
241 ..Default::default()
242 })
243 .collect(),
244 ops: alteration.ops.iter().map(op_to_wire).collect(),
245 ..Default::default()
246 })
247 .collect(),
248 validate_only,
249 ..Default::default()
250 }
251}
252
253fn alter_results(
258 alterations: &[ClientQuotaAlteration],
259 response: AlterClientQuotasResponse,
260) -> BTreeMap<ClientQuotaEntity, Result<(), KafkaError>> {
261 let mut out = BTreeMap::new();
262 for entry in response.entries {
263 let entity: ClientQuotaEntity = entry
264 .entity
265 .into_iter()
266 .map(|entity| (entity.entity_type, entity.entity_name))
267 .collect();
268 if !alterations
269 .iter()
270 .any(|alteration| alteration.entity == entity)
271 {
272 tracing::warn!(
273 ?entity,
274 "the AlterClientQuotas response names an entity that is not in the request"
275 );
276 continue;
277 }
278 let result = kafka_error_if(entry.error_code, entry.error_message).map_or(Ok(()), Err);
279 out.insert(entity, result);
280 }
281 for alteration in alterations {
282 out.entry(alteration.entity.clone()).or_insert_with(|| {
283 Err(KafkaError {
284 code: UNKNOWN_SERVER_ERROR,
285 name: kafka_error_name(UNKNOWN_SERVER_ERROR),
286 message: Some(format!(
287 "the AlterClientQuotas response did not contain a result for entity {:?}",
288 alteration.entity
289 )),
290 })
291 });
292 }
293 out
294}
295
296fn user_entity(username: &str) -> ClientQuotaEntity {
298 maplit::btreemap! {
299 ENTITY_USER.to_owned() => Some(username.to_owned()),
300 }
301}
302
303impl AdminClient {
304 pub async fn describe_client_quotas(
316 &self,
317 filter: &ClientQuotaFilter,
318 ) -> Result<ClientQuotas, AdminError> {
319 let retry = ControllerRetry::new("DescribeClientQuotas", self.retry);
320 let response = retry
321 .bounded(self.conn.send(describe_request(filter)))
322 .await?;
323 describe_result(response)
324 }
325
326 pub async fn alter_client_quotas(
338 &self,
339 alterations: &[ClientQuotaAlteration],
340 validate_only: bool,
341 ) -> Result<BTreeMap<ClientQuotaEntity, Result<(), KafkaError>>, AdminError> {
342 let retry = ControllerRetry::new("AlterClientQuotas", self.retry);
343 let response = retry
344 .bounded(self.conn.send(alter_request(alterations, validate_only)))
345 .await?;
346 Ok(alter_results(alterations, response))
347 }
348
349 pub(crate) async fn describe_user_quotas_impl(
351 &self,
352 username: &str,
353 ) -> Result<UserQuotaConfig, AdminError> {
354 let filter = ClientQuotaFilter::contains_only(vec![ClientQuotaFilterComponent::of_entity(
355 ENTITY_USER,
356 username,
357 )]);
358 Ok(self
359 .describe_client_quotas(&filter)
360 .await?
361 .into_values()
362 .flatten()
363 .collect())
364 }
365
366 pub(crate) async fn alter_user_quotas_impl(
368 &self,
369 username: &str,
370 ops: &[QuotaOp],
371 validate_only: bool,
372 ) -> Result<Option<KafkaError>, AdminError> {
373 if ops.is_empty() {
374 return Ok(None);
375 }
376 let alteration = ClientQuotaAlteration {
377 entity: user_entity(username),
378 ops: ops.to_vec(),
379 };
380 Ok(self
381 .alter_client_quotas(slice::from_ref(&alteration), validate_only)
382 .await?
383 .into_values()
384 .next()
385 .and_then(Result::err))
386 }
387}
388
389fn op_to_wire(op: &QuotaOp) -> AlterOp {
390 match op {
391 QuotaOp::Set { key, value } => AlterOp {
392 key: key.clone(),
393 value: *value,
394 ..Default::default()
395 },
396 QuotaOp::Remove { key } => AlterOp {
397 key: key.clone(),
398 remove: true,
399 ..Default::default()
400 },
401 }
402}
403
404#[must_use]
410pub fn diff_user_quotas(current: &UserQuotaConfig, desired: &UserQuotaConfig) -> Vec<QuotaOp> {
411 let mut ops = Vec::new();
412 for (k, v) in desired {
413 match current.get(k) {
414 Some(cur) if cur.to_bits() == v.to_bits() => {}
415 _ => ops.push(QuotaOp::Set {
416 key: k.clone(),
417 value: *v,
418 }),
419 }
420 }
421 for k in current.keys() {
422 if !desired.contains_key(k) {
423 ops.push(QuotaOp::Remove { key: k.clone() });
424 }
425 }
426 ops
427}
428
429#[cfg(test)]
430mod tests {
431 use std::sync::{Arc, Mutex};
432
433 use bytes::{Buf, BytesMut};
434 use krabka_client_core::MockBroker;
435 use krabka_protocol::{
436 Decode, Encode, UnknownTaggedFields,
437 owned::{
438 alter_client_quotas_request,
439 alter_client_quotas_response::{AlterClientQuotasResponse, EntityData, EntryData},
440 api_versions_request,
441 api_versions_response::{ApiVersion, ApiVersionsResponse},
442 describe_client_quotas_request,
443 describe_client_quotas_response::{
444 DescribeClientQuotasResponse, EntityData as DescribeEntityData,
445 EntryData as DescribeEntryData, ValueData as DescribeValueData,
446 },
447 },
448 };
449
450 use super::*;
451 use crate::AdminClientLike as _;
452
453 fn encode_v0(resp: &impl Encode) -> Vec<u8> {
454 let mut buf = BytesMut::new();
455 resp.encode(&mut buf, 0).unwrap();
456 buf.to_vec()
457 }
458
459 fn api_versions_response(api_key: i16, version: i16) -> Vec<u8> {
460 encode_v0(&ApiVersionsResponse {
461 api_keys: vec![
462 ApiVersion {
463 api_key: api_versions_request::API_KEY,
464 min_version: 0,
465 max_version: 0,
466 ..Default::default()
467 },
468 ApiVersion {
469 api_key,
470 min_version: version,
471 max_version: version,
472 ..Default::default()
473 },
474 ],
475 ..Default::default()
476 })
477 }
478
479 fn request_body_after_header(mut body: &[u8], flexible_header: bool) -> &[u8] {
480 let client_id_len = body.get_i16();
481 assert2::assert!(client_id_len >= 0);
482 body.advance(usize::try_from(client_id_len).expect("client id length is non-negative"));
483 if flexible_header {
484 assert2::assert!(body.get_u8() == 0);
485 }
486 body
487 }
488
489 #[test]
490 fn diff_no_change_returns_empty() {
491 let mut c = UserQuotaConfig::new();
492 c.insert("producer_byte_rate".into(), 1_048_576.0);
493 let d = c.clone();
494 assert2::assert!(diff_user_quotas(&c, &d).is_empty());
495 }
496
497 #[test]
498 fn diff_set_added_keys() {
499 let c = UserQuotaConfig::new();
500 let mut d = UserQuotaConfig::new();
501 d.insert("producer_byte_rate".into(), 1_048_576.0);
502 d.insert("request_percentage".into(), 25.0);
503 let ops = diff_user_quotas(&c, &d);
504 assert2::assert!(
506 ops == vec![
507 QuotaOp::Set {
508 key: "producer_byte_rate".to_owned(),
509 value: 1_048_576.0,
510 },
511 QuotaOp::Set {
512 key: "request_percentage".to_owned(),
513 value: 25.0,
514 },
515 ]
516 );
517 }
518
519 #[test]
520 fn diff_remove_dropped_keys() {
521 let mut c = UserQuotaConfig::new();
522 c.insert("producer_byte_rate".into(), 1.0);
523 c.insert("consumer_byte_rate".into(), 2.0);
524 let mut d = UserQuotaConfig::new();
525 d.insert("producer_byte_rate".into(), 1.0);
526 let ops = diff_user_quotas(&c, &d);
527 assert2::assert!(
528 ops == vec![QuotaOp::Remove {
529 key: "consumer_byte_rate".into()
530 }]
531 );
532 }
533
534 #[test]
535 fn diff_value_change_is_a_set() {
536 let mut c = UserQuotaConfig::new();
537 c.insert("producer_byte_rate".into(), 1.0);
538 let mut d = UserQuotaConfig::new();
539 d.insert("producer_byte_rate".into(), 2.0);
540 let ops = diff_user_quotas(&c, &d);
541 assert2::assert!(
542 ops == vec![QuotaOp::Set {
543 key: "producer_byte_rate".into(),
544 value: 2.0,
545 }]
546 );
547 }
548
549 #[test]
550 fn diff_mixed_add_change_remove() {
551 let mut c = UserQuotaConfig::new();
552 c.insert("producer_byte_rate".into(), 1.0);
553 c.insert("consumer_byte_rate".into(), 2.0);
554 let mut d = UserQuotaConfig::new();
555 d.insert("producer_byte_rate".into(), 5.0); d.insert("request_percentage".into(), 25.0); let ops = diff_user_quotas(&c, &d);
559 assert2::assert!(
562 ops == vec![
563 QuotaOp::Set {
564 key: "producer_byte_rate".to_owned(),
565 value: 5.0,
566 },
567 QuotaOp::Set {
568 key: "request_percentage".to_owned(),
569 value: 25.0,
570 },
571 QuotaOp::Remove {
572 key: "consumer_byte_rate".to_owned(),
573 },
574 ]
575 );
576 }
577
578 #[test]
579 fn op_to_wire_set() {
580 let op = QuotaOp::Set {
581 key: "producer_byte_rate".into(),
582 value: 1.0,
583 };
584 let w = op_to_wire(&op);
585 assert2::assert!(
586 w == AlterOp {
587 key: "producer_byte_rate".to_owned(),
588 value: 1.0,
589 remove: false,
590 unknown_tagged_fields: UnknownTaggedFields(vec![]),
591 }
592 );
593 }
594
595 #[test]
596 fn op_to_wire_remove_sends_zero_value_and_flag() {
597 let op = QuotaOp::Remove {
598 key: "producer_byte_rate".into(),
599 };
600 let w = op_to_wire(&op);
601 assert2::assert!(
602 w == AlterOp {
603 key: "producer_byte_rate".to_owned(),
604 value: 0.0,
605 remove: true,
606 unknown_tagged_fields: UnknownTaggedFields(vec![]),
607 }
608 );
609 }
610
611 fn entity(parts: &[(&str, Option<&str>)]) -> ClientQuotaEntity {
612 parts
613 .iter()
614 .map(|(entity_type, name)| ((*entity_type).to_owned(), name.map(str::to_owned)))
615 .collect()
616 }
617
618 fn component(entity_type: &str, match_type: i8, match_: Option<&str>) -> ComponentData {
619 ComponentData {
620 entity_type: entity_type.to_owned(),
621 match_type,
622 match_: match_.map(str::to_owned),
623 ..Default::default()
624 }
625 }
626
627 #[test]
631 fn describe_request_maps_each_filter_component() {
632 for (name, filter, expected) in [
633 (
634 "all entities",
635 ClientQuotaFilter::all(),
636 DescribeClientQuotasRequest::default(),
637 ),
638 (
639 "one user and any client id",
640 ClientQuotaFilter::contains_only(vec![
641 ClientQuotaFilterComponent::of_entity(ENTITY_USER, "alice"),
642 ClientQuotaFilterComponent::of_entity_type(ENTITY_CLIENT_ID),
643 ]),
644 DescribeClientQuotasRequest {
645 components: vec![
646 component("user", 0, Some("alice")),
647 component("client-id", 2, None),
648 ],
649 strict: true,
650 ..Default::default()
651 },
652 ),
653 (
654 "the default ip",
655 ClientQuotaFilter::contains(vec![ClientQuotaFilterComponent::of_default_entity(
656 ENTITY_IP,
657 )]),
658 DescribeClientQuotasRequest {
659 components: vec![component("ip", 1, None)],
660 strict: false,
661 ..Default::default()
662 },
663 ),
664 ] {
665 assert2::assert!(describe_request(&filter) == expected, "case {name}");
666 }
667 }
668
669 #[test]
673 fn alter_request_carries_each_entity_type() {
674 let alterations = [
675 ClientQuotaAlteration {
676 entity: entity(&[("user", Some("alice")), ("client-id", None)]),
677 ops: vec![QuotaOp::Set {
678 key: "producer_byte_rate".to_owned(),
679 value: 1024.0,
680 }],
681 },
682 ClientQuotaAlteration {
683 entity: entity(&[("ip", Some("10.0.0.1"))]),
684 ops: vec![QuotaOp::Remove {
685 key: "connection_creation_rate".to_owned(),
686 }],
687 },
688 ];
689 let alter_entity = |entity_type: &str, name: Option<&str>| AlterEntity {
690 entity_type: entity_type.to_owned(),
691 entity_name: name.map(str::to_owned),
692 ..Default::default()
693 };
694 assert2::assert!(
695 alter_request(&alterations, true)
696 == AlterClientQuotasRequest {
697 entries: vec![
698 AlterEntry {
699 entity: vec![
700 alter_entity("client-id", None),
701 alter_entity("user", Some("alice")),
702 ],
703 ops: vec![AlterOp {
704 key: "producer_byte_rate".to_owned(),
705 value: 1024.0,
706 ..Default::default()
707 }],
708 ..Default::default()
709 },
710 AlterEntry {
711 entity: vec![alter_entity("ip", Some("10.0.0.1"))],
712 ops: vec![AlterOp {
713 key: "connection_creation_rate".to_owned(),
714 remove: true,
715 ..Default::default()
716 }],
717 ..Default::default()
718 },
719 ],
720 validate_only: true,
721 ..Default::default()
722 }
723 );
724 }
725
726 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
730 async fn describe_client_quotas_maps_every_entity_type() {
731 use krabka_client_core::MockReply;
732
733 use crate::partition_leaders::test_support::{
734 ApiVersionRange, encode_response, scripted_broker,
735 };
736
737 let entries = vec![
738 DescribeEntryData {
739 entity: vec![
740 DescribeEntityData {
741 entity_type: "user".into(),
742 entity_name: Some("alice".into()),
743 ..Default::default()
744 },
745 DescribeEntityData {
746 entity_type: "client-id".into(),
747 entity_name: None,
748 ..Default::default()
749 },
750 ],
751 values: vec![DescribeValueData {
752 key: "producer_byte_rate".into(),
753 value: 1024.0,
754 ..Default::default()
755 }],
756 ..Default::default()
757 },
758 DescribeEntryData {
759 entity: vec![DescribeEntityData {
760 entity_type: "ip".into(),
761 entity_name: Some("10.0.0.1".into()),
762 ..Default::default()
763 }],
764 values: vec![DescribeValueData {
765 key: "connection_creation_rate".into(),
766 value: 5.0,
767 ..Default::default()
768 }],
769 ..Default::default()
770 },
771 ];
772 let quotas = ClientQuotas::from([
773 (
774 entity(&[("user", Some("alice")), ("client-id", None)]),
775 maplit::btreemap! {
776 "producer_byte_rate".to_owned() => 1024.0,
777 },
778 ),
779 (
780 entity(&[("ip", Some("10.0.0.1"))]),
781 maplit::btreemap! {
782 "connection_creation_rate".to_owned() => 5.0,
783 },
784 ),
785 ]);
786 for (name, close_first, error_code, expected) in [
787 ("success", false, 0, (Ok(quotas.clone()), 1)),
788 (
789 "a lost connection is retried",
790 true,
791 0,
792 (Ok(quotas.clone()), 2),
793 ),
794 (
795 "a top-level error fails the call",
796 false,
797 31,
798 (Err(("DescribeClientQuotas", 31)), 1),
799 ),
800 ] {
801 let requests = Arc::new(Mutex::new(0_usize));
802 let handler_requests = Arc::clone(&requests);
803 let entries = entries.clone();
804 let broker = scripted_broker(
805 vec![
806 ApiVersionRange::builder()
807 .api_key(describe_client_quotas_request::API_KEY)
808 .max_version(1)
809 .build(),
810 ],
811 move |api_key, version, _, _| {
812 if api_key != describe_client_quotas_request::API_KEY {
813 return MockReply::Silent;
814 }
815 let mut requests = handler_requests.lock().expect("requests lock");
816 *requests += 1;
817 let first = *requests == 1;
818 drop(requests);
819 if close_first && first {
820 return MockReply::Close;
821 }
822 MockReply::Respond(encode_response(
823 &DescribeClientQuotasResponse {
824 error_code,
825 entries: (error_code == 0).then(|| entries.clone()),
826 ..Default::default()
827 },
828 version,
829 true,
830 ))
831 },
832 )
833 .await;
834 let admin = AdminClient::connect(&[broker.addr.to_string()])
835 .await
836 .expect("admin connects");
837
838 let result = admin
839 .describe_client_quotas(&ClientQuotaFilter::all())
840 .await
841 .map_err(|error| match error {
842 AdminError::Broker { api, code, .. } => (api, code),
843 other => panic!("case {name}: unexpected error {other:?}"),
844 });
845
846 broker.stop();
847 assert2::assert!(
848 (result, *requests.lock().expect("requests lock")) == expected,
849 "case {name}"
850 );
851 }
852 }
853
854 #[test]
857 fn alter_results_give_each_entity_its_result() {
858 let alice = entity(&[("user", Some("alice")), ("client-id", Some("app"))]);
859 let ip = entity(&[("ip", None)]);
860 let alterations = [
861 ClientQuotaAlteration {
862 entity: alice.clone(),
863 ops: Vec::new(),
864 },
865 ClientQuotaAlteration {
866 entity: ip.clone(),
867 ops: Vec::new(),
868 },
869 ];
870 let answer = |entity_type: &str, entity_name: Option<&str>, error_code| EntryData {
871 error_code,
872 error_message: (error_code != 0).then(|| "bad".to_owned()),
873 entity: vec![EntityData {
874 entity_type: entity_type.to_owned(),
875 entity_name: entity_name.map(str::to_owned),
876 ..Default::default()
877 }],
878 ..Default::default()
879 };
880 let mut alice_answer = answer("user", Some("alice"), 0);
881 alice_answer.entity.push(EntityData {
882 entity_type: "client-id".to_owned(),
883 entity_name: Some("app".to_owned()),
884 ..Default::default()
885 });
886 let unknown = Err(KafkaError {
887 code: -1,
888 name: "UNKNOWN_SERVER_ERROR",
889 message: Some(format!(
890 "the AlterClientQuotas response did not contain a result for entity {ip:?}"
891 )),
892 });
893 for (name, entries, expected) in [
894 (
895 "each entity has its result",
896 vec![alice_answer.clone(), answer("ip", None, 42)],
897 maplit::btreemap! {
898 alice.clone() => Ok(()),
899 ip.clone() => Err(KafkaError {
900 code: 42,
901 name: "INVALID_REQUEST",
902 message: Some("bad".to_owned()),
903 }),
904 },
905 ),
906 (
907 "a missing entity and an unasked entity",
908 vec![alice_answer.clone(), answer("user", Some("bob"), 0)],
909 maplit::btreemap! {
910 alice => Ok(()),
911 ip => unknown,
912 },
913 ),
914 ] {
915 let response = AlterClientQuotasResponse {
916 entries,
917 ..Default::default()
918 };
919 assert2::assert!(
920 alter_results(&alterations, response) == expected,
921 "case {name}"
922 );
923 }
924 }
925
926 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
927 async fn describe_user_quotas_sends_strict_user_component() {
928 let seen_request = Arc::new(Mutex::new(None));
929 let captured_request = Arc::clone(&seen_request);
930 let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
931 if api_key == api_versions_request::API_KEY {
932 return Some(api_versions_response(
933 describe_client_quotas_request::API_KEY,
934 0,
935 ));
936 }
937 if api_key == describe_client_quotas_request::API_KEY {
938 let mut body = request_body_after_header(
939 body,
940 version >= describe_client_quotas_request::FLEXIBLE_MIN,
941 );
942 let request = DescribeClientQuotasRequest::decode(&mut body, version)
943 .expect("describe quotas request decodes");
944 assert2::assert!(body.is_empty());
945 *captured_request.lock().expect("request capture lock") = Some(request);
946 return Some(encode_v0(&DescribeClientQuotasResponse {
947 entries: Some(vec![DescribeEntryData {
948 entity: vec![DescribeEntityData {
949 entity_type: "user".into(),
950 entity_name: Some("alice".into()),
951 ..Default::default()
952 }],
953 values: vec![DescribeValueData {
954 key: "producer_byte_rate".into(),
955 value: 1024.0,
956 ..Default::default()
957 }],
958 ..Default::default()
959 }]),
960 ..Default::default()
961 }));
962 }
963 None
964 })
965 .await
966 .unwrap();
967 let mut admin = AdminClient::connect(&[mock.addr.to_string()])
968 .await
969 .expect("admin connects to mock broker");
970
971 let quotas = admin
972 .describe_user_quotas("alice")
973 .await
974 .expect("describe quotas response maps");
975
976 assert2::assert!(quotas.get("producer_byte_rate") == Some(&1024.0));
977 let request = seen_request
978 .lock()
979 .expect("request capture lock")
980 .take()
981 .expect("describe quotas request was captured");
982 assert2::assert!(
983 request
984 == DescribeClientQuotasRequest {
985 components: vec![ComponentData {
986 entity_type: "user".into(),
987 match_type: MATCH_TYPE_EXACT,
988 match_: Some("alice".into()),
989 ..Default::default()
990 }],
991 strict: true,
992 ..Default::default()
993 }
994 );
995 mock.stop();
996 }
997
998 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
999 async fn alter_user_quotas_surfaces_broker_entry_error() {
1000 let seen_request = Arc::new(Mutex::new(None));
1001 let captured_request = Arc::clone(&seen_request);
1002 let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
1003 if api_key == api_versions_request::API_KEY {
1004 return Some(api_versions_response(
1005 alter_client_quotas_request::API_KEY,
1006 0,
1007 ));
1008 }
1009 if api_key == alter_client_quotas_request::API_KEY {
1010 let mut body = request_body_after_header(
1011 body,
1012 version >= alter_client_quotas_request::FLEXIBLE_MIN,
1013 );
1014 let request = AlterClientQuotasRequest::decode(&mut body, version)
1015 .expect("alter quotas request decodes");
1016 assert2::assert!(body.is_empty());
1017 *captured_request.lock().expect("request capture lock") = Some(request);
1018 return Some(encode_v0(&AlterClientQuotasResponse {
1019 entries: vec![EntryData {
1020 error_code: 40,
1021 error_message: Some("invalid quota".into()),
1022 entity: vec![EntityData {
1023 entity_type: "user".into(),
1024 entity_name: Some("alice".into()),
1025 ..Default::default()
1026 }],
1027 ..Default::default()
1028 }],
1029 ..Default::default()
1030 }));
1031 }
1032 None
1033 })
1034 .await
1035 .unwrap();
1036 let mut admin = AdminClient::connect(&[mock.addr.to_string()])
1037 .await
1038 .expect("admin connects to mock broker");
1039
1040 let error = admin
1041 .alter_user_quotas(
1042 "alice",
1043 &[
1044 QuotaOp::Set {
1045 key: "producer_byte_rate".into(),
1046 value: 1024.0,
1047 },
1048 QuotaOp::Remove {
1049 key: "consumer_byte_rate".into(),
1050 },
1051 ],
1052 true,
1053 )
1054 .await
1055 .expect("alter quotas maps broker entry")
1056 .expect("non-zero broker error is returned");
1057
1058 assert2::assert!((error.code, error.message.as_deref()) == (40, Some("invalid quota")));
1059 let request = seen_request
1060 .lock()
1061 .expect("request capture lock")
1062 .take()
1063 .expect("alter quotas request was captured");
1064 assert2::assert!(
1065 request
1066 == AlterClientQuotasRequest {
1067 entries: vec![AlterEntry {
1068 entity: vec![AlterEntity {
1069 entity_type: "user".into(),
1070 entity_name: Some("alice".into()),
1071 ..Default::default()
1072 }],
1073 ops: vec![
1074 AlterOp {
1075 key: "producer_byte_rate".into(),
1076 value: 1024.0,
1077 ..Default::default()
1078 },
1079 AlterOp {
1080 key: "consumer_byte_rate".into(),
1081 remove: true,
1082 ..Default::default()
1083 },
1084 ],
1085 ..Default::default()
1086 }],
1087 validate_only: true,
1088 ..Default::default()
1089 }
1090 );
1091 mock.stop();
1092 }
1093}