1mod acl_index;
6
7use std::{
8 collections::{BTreeMap, HashMap, HashSet},
9 ops::Bound,
10};
11
12use krabka_security::{KafkaPrincipal, SaslMechanism, ScramCredential};
13use krabka_units::{ByteRate, convert::ByteRateExt};
14use uuid::Uuid;
15
16use self::acl_index::AclIndex;
17use crate::{
18 acl::{AclEntry, PatternType, ResourceType},
19 break_glass::{BreakGlassApproval, BreakGlassProposalRecord},
20 directory_id::MIGRATING,
21 error::MetadataError,
22 metadata_version,
23 records::{
24 BrokerConfigRecord, BrokerRegistrationChangeRecord, BrokerRegistrationRecord,
25 ClientMetricsConfigRecord, ClientQuotaRecord, ControllerRegistrationRecord,
26 DEFAULT_BROKER_CONFIG_NODE_ID, DelegationTokenRecord, FeatureLevelRecord,
27 FeaturesEpochRecord, GroupConfigRecord, KRaftVersionRecord, LeaderRecoveryState,
28 MetadataRecord, NodeId, PartitionDirAssignmentRecord, PartitionElrRecord,
29 PartitionOffsetAdvanceRecord, PartitionRecord, PartitionRecoveryRecord, ProducerIdsRecord,
30 QuotaEntity, ScramCredentialRecord, TopicConfigRecord, TopicRecord, UnregisterBrokerRecord,
31 VotersRecord,
32 },
33 voters::VoterSet,
34 write_freeze::TopicFreezeRecord,
35};
36
37pub type EntityKey = Vec<(String, Option<String>)>;
38
39type PartitionElrState = (Vec<NodeId>, Vec<NodeId>);
41
42#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DelegationToken {
47 pub token_id: String,
48 pub owner: KafkaPrincipal,
49 pub requester: KafkaPrincipal,
50 pub issue_timestamp_ms: i64,
51 pub expiry_timestamp_ms: i64,
52 pub max_timestamp_ms: i64,
53 pub renewers: Vec<KafkaPrincipal>,
54}
55
56impl DelegationToken {
57 #[must_use]
58 pub fn from_record(rec: &DelegationTokenRecord) -> Self {
59 Self {
60 token_id: rec.token_id.clone(),
61 owner: rec.owner.clone(),
62 requester: rec.requester.clone(),
63 issue_timestamp_ms: rec.issue_timestamp_ms,
64 expiry_timestamp_ms: rec.expiry_timestamp_ms,
65 max_timestamp_ms: rec.max_timestamp_ms,
66 renewers: rec.renewers.clone(),
67 }
68 }
69
70 #[must_use]
72 pub fn to_record(&self) -> DelegationTokenRecord {
73 DelegationTokenRecord {
74 token_id: self.token_id.clone(),
75 owner: self.owner.clone(),
76 requester: self.requester.clone(),
77 issue_timestamp_ms: self.issue_timestamp_ms,
78 expiry_timestamp_ms: self.expiry_timestamp_ms,
79 max_timestamp_ms: self.max_timestamp_ms,
80 renewers: self.renewers.clone(),
81 }
82 }
83
84 #[must_use]
87 pub fn owner_or_renewer(&self, principal: &KafkaPrincipal) -> bool {
88 &self.owner == principal
89 || &self.requester == principal
90 || self.renewers.contains(principal)
91 }
92}
93
94#[must_use]
95pub fn canonicalize_entity(mut tuple: Vec<(String, Option<String>)>) -> EntityKey {
96 tuple.sort_by(|a, b| a.0.cmp(&b.0));
97 tuple
98}
99
100fn record_variant(rec: &MetadataRecord) -> &'static str {
104 match rec {
105 MetadataRecord::V1Topic(_) => "V1Topic",
106 MetadataRecord::V1Partition(_) => "V1Partition",
107 MetadataRecord::V1BrokerRegistration(_) => "V1BrokerRegistration",
108 MetadataRecord::V1DeleteTopic(_) => "V1DeleteTopic",
109 MetadataRecord::V1TopicConfig(_) => "V1TopicConfig",
110 MetadataRecord::V1ScramCredential(_) => "V1ScramCredential",
111 MetadataRecord::V1DeleteScramCredential(_) => "V1DeleteScramCredential",
112 MetadataRecord::V1AccessControlEntry(_) => "V1AccessControlEntry",
113 MetadataRecord::V1DeleteAccessControlEntry(_) => "V1DeleteAccessControlEntry",
114 MetadataRecord::V1BrokerConfig(_) => "V1BrokerConfig",
115 MetadataRecord::V1ClientQuota(_) => "V1ClientQuota",
116 MetadataRecord::V1ProducerIds(_) => "V1ProducerIds",
117 MetadataRecord::V1DelegationToken(_) => "V1DelegationToken",
118 MetadataRecord::V1DeleteDelegationToken(_) => "V1DeleteDelegationToken",
119 MetadataRecord::V1UnregisterBroker(_) => "V1UnregisterBroker",
120 MetadataRecord::V1KRaftVersion(_) => "V1KRaftVersion",
121 MetadataRecord::V1Voters(_) => "V1Voters",
122 MetadataRecord::V1FeatureLevel(_) => "V1FeatureLevel",
123 MetadataRecord::V1ClientMetricsConfig(_) => "V1ClientMetricsConfig",
124 MetadataRecord::V1FeaturesEpoch(_) => "V1FeaturesEpoch",
125 MetadataRecord::V1PartitionDirAssignment(_) => "V1PartitionDirAssignment",
126 MetadataRecord::V1PartitionOffsetAdvance(_) => "V1PartitionOffsetAdvance",
127 MetadataRecord::V1GroupConfig(_) => "V1GroupConfig",
128 MetadataRecord::V1ControllerRegistration(_) => "V1ControllerRegistration",
129 MetadataRecord::V1TopicFreeze(_) => "V1TopicFreeze",
130 MetadataRecord::V1BreakGlassProposal(_) => "V1BreakGlassProposal",
131 MetadataRecord::V1DeleteBreakGlassProposal(_) => "V1DeleteBreakGlassProposal",
132 MetadataRecord::V1PartitionElr(_) => "V1PartitionElr",
133 MetadataRecord::V1PartitionRecovery(_) => "V1PartitionRecovery",
134 MetadataRecord::V1PartitionUpdate(_) => "V1PartitionUpdate",
135 MetadataRecord::V1BrokerRegistrationChange(_) => "V1BrokerRegistrationChange",
136 MetadataRecord::V1UnregisterController(_) => "V1UnregisterController",
137 }
138}
139
140#[derive(Debug, Clone, Default, PartialEq)]
141pub struct MetadataImage {
142 cluster_id: Uuid,
143 topics: HashMap<String, TopicRecord>,
144 topic_ids: HashMap<Uuid, String>,
148 partitions: HashMap<String, BTreeMap<i32, PartitionRecord>>,
156 partition_next_offsets: HashMap<String, BTreeMap<i32, i64>>,
158 partition_elr: HashMap<String, BTreeMap<i32, PartitionElrState>>,
159 partition_recovery: HashMap<String, BTreeMap<i32, LeaderRecoveryState>>,
160 brokers: HashMap<NodeId, BrokerRegistrationRecord>,
161 controllers: HashMap<NodeId, ControllerRegistrationRecord>,
162 topic_configs: HashMap<String, BTreeMap<String, String>>,
163 broker_configs: HashMap<NodeId, BTreeMap<String, String>>,
164 client_metrics_configs: HashMap<String, BTreeMap<String, String>>,
165 group_configs: HashMap<String, BTreeMap<String, String>>,
166 scram_credentials: HashMap<(String, SaslMechanism), ScramCredential>,
167 acls: AclIndex,
168 freezes_literal: HashMap<String, TopicFreezeRecord>,
173 freezes_prefixed: BTreeMap<String, TopicFreezeRecord>,
174 break_glass: HashMap<Uuid, BreakGlassProposalRecord>,
176 client_quotas: HashMap<EntityKey, BTreeMap<String, f64>>,
177 producer_ids: Option<ProducerIdsRecord>,
178 delegation_tokens: HashMap<String, DelegationToken>,
179 kraft_version: u16,
180 voters: VoterSet,
181 feature_levels: BTreeMap<String, i16>,
182 features_epoch: i64,
187}
188
189fn extends(stored: &[BreakGlassApproval], candidate: &[BreakGlassApproval]) -> bool {
195 candidate.starts_with(stored)
196}
197
198#[derive(Debug, Clone, Copy)]
200pub enum ThrottleKind {
201 Leader,
202 Follower,
203 AlterLogDirs,
204}
205
206impl MetadataImage {
207 #[must_use]
208 pub fn new(cluster_id: Uuid) -> Self {
209 Self {
210 cluster_id,
211 topics: HashMap::new(),
212 topic_ids: HashMap::new(),
213 partitions: HashMap::new(),
214 partition_next_offsets: HashMap::new(),
215 partition_elr: HashMap::new(),
216 partition_recovery: HashMap::new(),
217 brokers: HashMap::new(),
218 controllers: HashMap::new(),
219 topic_configs: HashMap::new(),
220 broker_configs: HashMap::new(),
221 client_metrics_configs: HashMap::new(),
222 group_configs: HashMap::new(),
223 scram_credentials: HashMap::new(),
224 acls: AclIndex::default(),
225 freezes_literal: HashMap::new(),
226 freezes_prefixed: BTreeMap::new(),
227 break_glass: HashMap::new(),
228 client_quotas: HashMap::new(),
229 producer_ids: None,
230 delegation_tokens: HashMap::new(),
231 kraft_version: 0,
232 voters: VoterSet::default(),
233 feature_levels: BTreeMap::new(),
234 features_epoch: -1,
235 }
236 }
237
238 #[must_use]
239 pub fn cluster_id(&self) -> Uuid {
240 self.cluster_id
241 }
242
243 pub fn topics(&self) -> impl Iterator<Item = &TopicRecord> {
244 self.topics.values()
245 }
246
247 #[tracing::instrument(level = "debug", skip_all, fields(topic = %name))]
248 #[must_use]
249 pub fn topic(&self, name: &str) -> Option<&TopicRecord> {
250 self.topics.get(name)
251 }
252
253 #[tracing::instrument(level = "debug", skip_all, fields(topic_id = %id))]
255 #[must_use]
256 pub fn topic_by_id(&self, id: &Uuid) -> Option<&TopicRecord> {
257 let name = self.topic_ids.get(id)?;
258 self.topics.get(name)
259 }
260
261 #[must_use]
263 pub fn topic_name_by_id(&self, id: &Uuid) -> Option<&str> {
264 self.topic_ids.get(id).map(String::as_str)
265 }
266
267 #[tracing::instrument(level = "debug", skip_all, fields(topic = %topic, partition = idx))]
268 #[must_use]
269 pub fn partition(&self, topic: &str, idx: i32) -> Option<&PartitionRecord> {
270 self.partitions.get(topic)?.get(&idx)
271 }
272
273 pub fn partitions_of(&self, topic: &str) -> impl Iterator<Item = &PartitionRecord> {
279 self.partitions
280 .get(topic)
281 .into_iter()
282 .flat_map(BTreeMap::values)
283 }
284
285 #[must_use]
288 pub fn partition_next_offset(&self, topic: &str, partition: i32) -> Option<i64> {
289 self.partition_next_offsets
290 .get(topic)
291 .and_then(|partitions| partitions.get(&partition))
292 .copied()
293 }
294
295 #[must_use]
296 pub fn partition_elr(&self, topic: &str, partition: i32) -> (&[NodeId], &[NodeId]) {
297 self.partition_elr
298 .get(topic)
299 .and_then(|partitions| partitions.get(&partition))
300 .map_or((&[], &[]), |(eligible, last_known)| {
301 (eligible.as_slice(), last_known.as_slice())
302 })
303 }
304
305 #[must_use]
306 pub fn leader_recovery_state(&self, topic: &str, partition: i32) -> LeaderRecoveryState {
307 self.partition_recovery
308 .get(topic)
309 .and_then(|partitions| partitions.get(&partition))
310 .copied()
311 .unwrap_or_default()
312 }
313
314 #[must_use]
322 pub fn topic_partition_count(&self, topic: &str) -> i32 {
323 self.partitions
324 .get(topic)
325 .map_or(0, |parts| i32::try_from(parts.len()).unwrap_or(i32::MAX))
326 }
327
328 fn total_partitions(&self) -> usize {
330 self.partitions.values().map(BTreeMap::len).sum()
331 }
332
333 pub fn all_partitions(&self) -> impl Iterator<Item = &PartitionRecord> {
340 self.partitions.values().flat_map(BTreeMap::values)
341 }
342
343 pub fn reassignments_in_flight(&self) -> impl Iterator<Item = &PartitionRecord> + '_ {
346 self.all_partitions()
347 .filter(|p| !p.adding_replicas.is_empty() || !p.removing_replicas.is_empty())
348 }
349
350 #[must_use]
354 pub fn topic_config(&self, topic: &str) -> Option<&BTreeMap<String, String>> {
355 self.topic_configs.get(topic)
356 }
357
358 #[must_use]
361 pub fn broker_config(&self, node_id: NodeId) -> Option<&BTreeMap<String, String>> {
362 self.broker_configs.get(&node_id)
363 }
364
365 #[must_use]
368 pub fn default_broker_config(&self) -> Option<&BTreeMap<String, String>> {
369 self.broker_config(DEFAULT_BROKER_CONFIG_NODE_ID)
370 }
371
372 #[must_use]
376 pub fn broker_throttle_rate(&self, node_id: NodeId, kind: ThrottleKind) -> Option<ByteRate> {
377 let key = match kind {
378 ThrottleKind::Leader => "leader.replication.throttled.rate",
379 ThrottleKind::Follower => "follower.replication.throttled.rate",
380 ThrottleKind::AlterLogDirs => "replica.alter.log.dirs.io.max.bytes.per.second",
381 };
382 let raw = self
383 .broker_config(node_id)
384 .and_then(|configs| configs.get(key))
385 .or_else(|| self.default_broker_config()?.get(key))?;
386 let v: i64 = raw.parse().ok()?;
387 (v >= 0).then(|| ByteRate::from_bytes_per_sec(v))
388 }
389
390 #[must_use]
392 pub fn client_metrics_config(&self, name: &str) -> Option<&BTreeMap<String, String>> {
393 self.client_metrics_configs.get(name)
394 }
395
396 pub fn client_metrics_subscriptions(
398 &self,
399 ) -> impl Iterator<Item = (&String, &BTreeMap<String, String>)> {
400 self.client_metrics_configs.iter()
401 }
402
403 #[must_use]
405 pub fn group_config(&self, group_id: &str) -> Option<&BTreeMap<String, String>> {
406 self.group_configs.get(group_id)
407 }
408
409 pub fn group_configs(&self) -> impl Iterator<Item = (&String, &BTreeMap<String, String>)> {
411 self.group_configs.iter()
412 }
413
414 #[must_use]
415 pub fn client_quotas(&self) -> &HashMap<EntityKey, BTreeMap<String, f64>> {
416 &self.client_quotas
417 }
418
419 #[must_use]
420 pub fn scram_credential(
421 &self,
422 user: &str,
423 mechanism: SaslMechanism,
424 ) -> Option<&ScramCredential> {
425 self.scram_credentials.get(&(user.to_owned(), mechanism))
426 }
427
428 #[must_use]
431 pub fn scram_credentials_users(&self) -> Vec<String> {
432 self.scram_credentials
433 .keys()
434 .map(|(u, _)| u.clone())
435 .collect::<HashSet<_>>()
436 .into_iter()
437 .collect()
438 }
439
440 #[must_use]
443 pub fn scram_credentials_for_user(&self, user: &str) -> Vec<(SaslMechanism, u32)> {
444 self.scram_credentials
445 .iter()
446 .filter(|((u, _), _)| u == user)
447 .map(|((_, mech), cred)| (*mech, cred.iterations))
448 .collect()
449 }
450
451 #[must_use]
452 pub fn broker(&self, node_id: NodeId) -> Option<&BrokerRegistrationRecord> {
453 self.brokers.get(&node_id)
454 }
455
456 #[must_use]
457 pub fn controller(&self, node_id: NodeId) -> Option<&ControllerRegistrationRecord> {
458 self.controllers.get(&node_id)
459 }
460
461 #[must_use]
464 pub fn broker_epoch(&self, node_id: NodeId) -> Option<i64> {
465 self.brokers.get(&node_id).map(|b| b.broker_epoch)
466 }
467
468 #[must_use]
470 pub fn next_producer_id(&self) -> i64 {
471 self.producer_ids.as_ref().map_or(0, |r| r.next_producer_id)
472 }
473
474 pub fn brokers(&self) -> impl Iterator<Item = &BrokerRegistrationRecord> {
475 self.brokers.values()
476 }
477
478 pub fn controllers(&self) -> impl Iterator<Item = &ControllerRegistrationRecord> {
479 self.controllers.values()
480 }
481
482 #[must_use]
483 pub fn kraft_version(&self) -> u16 {
484 self.kraft_version
485 }
486
487 #[must_use]
488 pub fn voters(&self) -> &VoterSet {
489 &self.voters
490 }
491
492 pub fn matching_acls<'a>(
504 &'a self,
505 rt: ResourceType,
506 rn: &'a str,
507 ) -> impl Iterator<Item = &'a AclEntry> + 'a {
508 self.acls.matching(rt, rn)
509 }
510
511 pub fn all_acls(&self) -> impl Iterator<Item = &AclEntry> {
514 self.acls.iter()
515 }
516
517 #[must_use]
529 pub fn topic_freeze(&self, topic: &str) -> Option<&TopicFreezeRecord> {
530 if self.freezes_literal.is_empty() && self.freezes_prefixed.is_empty() {
531 return None;
532 }
533 if topic.starts_with("__") {
534 return None;
535 }
536 if let Some(freeze) = self.freezes_literal.get(topic) {
537 return Some(freeze);
538 }
539 self.freezes_prefixed
544 .range::<str, _>((Bound::Unbounded, Bound::Included(topic)))
545 .rev()
546 .find(|(scope, _)| topic.starts_with(scope.as_str()))
547 .map(|(_, freeze)| freeze)
548 }
549
550 pub fn topic_freezes(&self) -> impl Iterator<Item = &TopicFreezeRecord> {
554 self.freezes_literal
555 .values()
556 .chain(self.freezes_prefixed.values())
557 }
558
559 #[must_use]
561 pub fn break_glass_proposal(&self, id: Uuid) -> Option<&BreakGlassProposalRecord> {
562 self.break_glass.get(&id)
563 }
564
565 pub fn break_glass_proposals(&self) -> impl Iterator<Item = &BreakGlassProposalRecord> {
569 self.break_glass.values()
570 }
571
572 #[must_use]
574 pub fn delegation_token_by_id(&self, token_id: &str) -> Option<&DelegationToken> {
575 self.delegation_tokens.get(token_id)
576 }
577
578 #[must_use]
581 pub fn delegation_tokens_by_owner(&self, owner: &KafkaPrincipal) -> Vec<&DelegationToken> {
582 self.delegation_tokens
583 .values()
584 .filter(|t| &t.owner == owner)
585 .collect()
586 }
587
588 #[must_use]
592 pub fn delegation_tokens_visible_to(
593 &self,
594 principal: &KafkaPrincipal,
595 ) -> Vec<&DelegationToken> {
596 self.delegation_tokens
597 .values()
598 .filter(|t| t.owner_or_renewer(principal))
599 .collect()
600 }
601
602 pub fn all_delegation_tokens(&self) -> impl Iterator<Item = &DelegationToken> {
606 self.delegation_tokens.values()
607 }
608
609 #[must_use]
617 pub fn delegation_token_by_hmac(
618 &self,
619 secret_key: &[u8],
620 hmac: &[u8],
621 ) -> Option<&DelegationToken> {
622 self.delegation_tokens
623 .values()
624 .find(|t| krabka_security::compute_token_hmac(secret_key, &t.token_id) == hmac)
625 }
626
627 #[must_use]
630 pub fn finalized_features(&self) -> &BTreeMap<String, i16> {
631 &self.feature_levels
632 }
633
634 #[must_use]
637 pub fn finalized_features_epoch(&self) -> i64 {
638 self.features_epoch
639 }
640
641 #[must_use]
645 pub fn finalized_metadata_version(&self) -> Option<i16> {
646 self.feature_levels
647 .get(metadata_version::METADATA_VERSION_FEATURE)
648 .copied()
649 }
650
651 #[must_use]
655 pub fn finalized_feature(&self, name: &str) -> Option<i16> {
656 self.feature_levels.get(name).copied()
657 }
658
659 #[must_use]
664 pub fn min_required_metadata_version(&self) -> i16 {
665 use crate::metadata_version::{
666 CIDR_ACL_MIN_LEVEL, DELEGATION_TOKEN_MIN_LEVEL, METADATA_VERSION_MIN, SCRAM_MIN_LEVEL,
667 };
668 let mut floor = METADATA_VERSION_MIN;
669 if !self.scram_credentials.is_empty() {
670 floor = floor.max(SCRAM_MIN_LEVEL);
671 }
672 if !self.delegation_tokens.is_empty() {
673 floor = floor.max(DELEGATION_TOKEN_MIN_LEVEL);
674 }
675 if self.all_acls().any(|acl| is_cidr_acl_host(&acl.host)) {
676 floor = floor.max(CIDR_ACL_MIN_LEVEL);
677 }
678 floor
679 }
680
681 #[must_use]
686 pub fn metadata_version_downgrade_records(&self, target: i16) -> Vec<MetadataRecord> {
687 use crate::{
688 metadata_version::{
689 DELEGATION_TOKEN_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL, SCRAM_MIN_LEVEL,
690 },
691 records::{DeleteDelegationTokenRecord, DeleteScramCredentialRecord},
692 };
693
694 let mut records = Vec::new();
695 if target < metadata_version::CIDR_ACL_MIN_LEVEL {
696 let mut cidr_acls: Vec<&AclEntry> = self
697 .all_acls()
698 .filter(|acl| is_cidr_acl_host(&acl.host))
699 .collect();
700 cidr_acls.sort_by(|a, b| {
701 (&a.resource_name, &a.principal, &a.host).cmp(&(
702 &b.resource_name,
703 &b.principal,
704 &b.host,
705 ))
706 });
707 records.extend(cidr_acls.into_iter().map(|acl| {
708 MetadataRecord::V1DeleteAccessControlEntry(crate::AclEntryFilter {
709 resource_type: Some(acl.resource_type),
710 resource_name: Some(acl.resource_name.clone()),
711 pattern_type: Some(acl.pattern_type),
712 principal: Some(acl.principal.clone()),
713 host: Some(acl.host.clone()),
714 operation: Some(acl.operation),
715 permission_type: Some(acl.permission_type),
716 })
717 }));
718 }
719 if target < SCRAM_MIN_LEVEL {
720 records.extend(self.scram_credentials.keys().map(|(user, mechanism)| {
721 MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
722 user: user.clone(),
723 mechanism: *mechanism,
724 })
725 }));
726 }
727 if target < DELEGATION_TOKEN_MIN_LEVEL {
728 records.extend(self.delegation_tokens.keys().map(|token_id| {
729 MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
730 token_id: token_id.clone(),
731 })
732 }));
733 }
734 records.extend(self.brokers.values().filter_map(|broker| {
735 let mut projected = broker.clone();
736 if target < DIRECTORY_ASSIGNMENT_MIN_LEVEL {
737 projected.log_dirs.clear();
738 }
739 if target < metadata_version::CORDONED_LOG_DIRS_MIN_LEVEL {
740 projected.cordoned_log_dirs = None;
741 }
742 (projected != *broker).then_some(MetadataRecord::V1BrokerRegistration(projected))
743 }));
744 if target < DIRECTORY_ASSIGNMENT_MIN_LEVEL {
747 for partition in self.all_partitions() {
748 records.extend(
749 partition
750 .replicas
751 .iter()
752 .zip(&partition.directories)
753 .filter(|(_, directory)| **directory != MIGRATING)
754 .map(|(replica, _)| {
755 MetadataRecord::V1PartitionDirAssignment(PartitionDirAssignmentRecord {
756 topic: partition.topic.clone(),
757 partition: partition.partition,
758 replica: *replica,
759 directory: MIGRATING,
760 })
761 }),
762 );
763 }
764 }
765 records
766 }
767
768 #[tracing::instrument(level = "info", skip_all, fields(record = record_variant(rec)))]
774 pub fn apply(&mut self, rec: &MetadataRecord) {
776 match rec {
777 MetadataRecord::V1Topic(t) => {
778 if let Some(prev) = self.topics.get(&t.name)
781 && prev.topic_id != t.topic_id
782 {
783 self.topic_ids.remove(&prev.topic_id);
784 }
785 self.topic_ids.insert(t.topic_id, t.name.clone());
786 let mut rec = t.clone();
795 rec.partitions = self.topics.get(&t.name).map_or(0, |prev| prev.partitions);
796 self.topics.insert(t.name.clone(), rec);
797 }
798 MetadataRecord::V1Partition(p) => self.apply_partition(p),
799 MetadataRecord::V1BrokerRegistration(b) => {
800 self.brokers.insert(b.node_id, b.clone());
801 }
802 MetadataRecord::V1BrokerRegistrationChange(change) => {
803 self.apply_registration_change(change);
804 }
805 MetadataRecord::V1UnregisterBroker(rec) => self.apply_unregister_broker(rec),
806 MetadataRecord::V1ControllerRegistration(c) => {
807 self.controllers.insert(c.node_id, c.clone());
808 }
809 MetadataRecord::V1UnregisterController(rec) => {
810 self.controllers.remove(&rec.node_id);
811 }
812 MetadataRecord::V1DeleteTopic(d) => {
813 if let Some(prev) = self.topics.get(&d.name) {
814 self.topic_ids.remove(&prev.topic_id);
815 }
816 self.topics.remove(&d.name);
817 self.partitions.remove(&d.name);
818 self.partition_next_offsets.remove(&d.name);
819 self.partition_elr.remove(&d.name);
820 self.partition_recovery.remove(&d.name);
821 self.topic_configs.remove(&d.name);
822 }
823 MetadataRecord::V1TopicConfig(c) => {
824 if c.overrides.is_empty() {
825 self.topic_configs.remove(&c.topic);
826 } else {
827 self.topic_configs
828 .insert(c.topic.clone(), c.overrides.clone());
829 }
830 }
831 MetadataRecord::V1ScramCredential(r) => {
832 self.scram_credentials.insert(
833 (r.user.clone(), r.mechanism),
834 ScramCredential {
835 mechanism: r.mechanism,
836 salt: r.salt.clone(),
837 stored_key: r.stored_key.clone(),
838 server_key: r.server_key.clone(),
839 iterations: r.iterations,
840 },
841 );
842 }
843 MetadataRecord::V1DeleteScramCredential(r) => {
844 self.scram_credentials
845 .remove(&(r.user.clone(), r.mechanism));
846 }
847 MetadataRecord::V1AccessControlEntry(entry) => self.acls.insert(entry),
848 MetadataRecord::V1DeleteAccessControlEntry(filter) => {
849 self.acls.remove_matching(filter);
850 }
851 MetadataRecord::V1BrokerConfig(rec) => {
852 let entry = self.broker_configs.entry(rec.node_id).or_default();
853 match &rec.config_value {
854 Some(v) => {
855 entry.insert(rec.config_name.clone(), v.clone());
856 }
857 None => {
858 entry.remove(&rec.config_name);
859 }
860 }
861 }
862 MetadataRecord::V1ClientQuota(rec) => {
863 let key = canonicalize_entity(
864 rec.entity
865 .iter()
866 .map(|e| (e.entity_type.clone(), e.entity_name.clone()))
867 .collect(),
868 );
869 let configs = self.client_quotas.entry(key).or_default();
870 match rec.config_value {
871 Some(v) => {
872 configs.insert(rec.config_key.clone(), v);
873 }
874 None => {
875 configs.remove(&rec.config_key);
876 }
877 }
878 }
879 MetadataRecord::V1ProducerIds(rec) => {
880 if rec.next_producer_id > self.next_producer_id() {
881 self.producer_ids = Some(*rec);
882 } else {
883 tracing::error!(
884 current = self.next_producer_id(),
885 proposed = rec.next_producer_id,
886 "ignoring non-monotonic producer ID allocation record"
887 );
888 }
889 }
890 MetadataRecord::V1DelegationToken(rec) => {
894 self.delegation_tokens
895 .insert(rec.token_id.clone(), DelegationToken::from_record(rec));
896 }
897 MetadataRecord::V1DeleteDelegationToken(rec) => {
898 self.delegation_tokens.remove(&rec.token_id);
899 }
900 MetadataRecord::V1KRaftVersion(r) => {
901 self.kraft_version = r.kraft_version;
902 }
903 MetadataRecord::V1Voters(r) => {
904 self.voters = r.voters.clone();
905 }
906 MetadataRecord::V1FeatureLevel(rec) => {
907 if rec.level == 0 {
908 self.feature_levels.remove(&rec.name);
909 } else {
910 self.feature_levels.insert(rec.name.clone(), rec.level);
911 }
912 self.features_epoch = self.features_epoch.saturating_add(1).max(0);
914 }
915 MetadataRecord::V1ClientMetricsConfig(c) => {
916 if c.configs.is_empty() {
917 self.client_metrics_configs.remove(&c.name);
918 } else {
919 self.client_metrics_configs
920 .insert(c.name.clone(), c.configs.clone());
921 }
922 }
923 MetadataRecord::V1GroupConfig(c) => {
924 if c.configs.is_empty() {
925 self.group_configs.remove(&c.group_id);
926 } else {
927 self.group_configs
928 .insert(c.group_id.clone(), c.configs.clone());
929 }
930 }
931 MetadataRecord::V1FeaturesEpoch(rec) => {
936 self.features_epoch = rec.epoch;
937 }
938 MetadataRecord::V1PartitionDirAssignment(r) => {
943 if let Some(pr) = self
944 .partitions
945 .get_mut(&r.topic)
946 .and_then(|parts| parts.get_mut(&r.partition))
947 && let Some(slot) = pr.replicas.iter().position(|n| *n == r.replica)
948 {
949 if pr.directories.len() < pr.replicas.len() {
952 pr.directories.resize(pr.replicas.len(), MIGRATING);
953 }
954 if let Some(directory) = pr.directories.get_mut(slot) {
955 *directory = r.directory;
956 }
957 }
958 }
959 MetadataRecord::V1PartitionOffsetAdvance(r) => {
961 *self
962 .partition_next_offsets
963 .entry(r.topic.clone())
964 .or_default()
965 .entry(r.partition)
966 .or_insert(0) += r.count;
967 }
968 MetadataRecord::V1PartitionElr(r) => self.apply_partition_elr(r),
969 MetadataRecord::V1PartitionRecovery(r) => {
970 self.apply_partition_recovery(&r.topic, r.partition, r.state);
971 }
972 MetadataRecord::V1PartitionUpdate(r) => self.apply_partition_update(r),
973 MetadataRecord::V1TopicFreeze(r) => self.apply_topic_freeze(r),
975 MetadataRecord::V1BreakGlassProposal(r) => {
979 self.break_glass.insert(r.proposal_id, r.clone());
980 }
981 MetadataRecord::V1DeleteBreakGlassProposal(id) => {
982 self.break_glass.remove(id);
983 }
984 }
985 }
986
987 fn apply_registration_change(&mut self, change: &BrokerRegistrationChangeRecord) {
991 if let Some(broker) = self.brokers.get_mut(&change.node_id)
992 && broker.broker_epoch == change.broker_epoch
993 {
994 *broker = change.applied_to(broker);
995 }
996 }
997
998 fn apply_unregister_broker(&mut self, rec: &UnregisterBrokerRecord) {
1002 if self.broker_epoch(rec.node_id) == Some(rec.broker_epoch) {
1003 self.brokers.remove(&rec.node_id);
1004 }
1005 }
1006
1007 fn apply_partition(&mut self, partition: &PartitionRecord) {
1008 let is_new = self
1009 .partitions
1010 .entry(partition.topic.clone())
1011 .or_default()
1012 .insert(partition.partition, partition.clone())
1013 .is_none();
1014 if let Some(topic) = self.topics.get_mut(&partition.topic) {
1015 if is_new {
1016 topic.partitions = topic.partitions.saturating_add(1);
1017 }
1018 topic.replication_factor =
1019 i16::try_from(partition.replicas.len()).unwrap_or(topic.replication_factor);
1020 }
1021 }
1022
1023 fn apply_partition_update(&mut self, update: &crate::PartitionUpdateRecord) {
1024 let key = (update.partition.topic.clone(), update.partition.partition);
1025 self.apply_partition(&update.partition);
1026 if let (Some(eligible), Some(last_known)) =
1027 (&update.eligible_leader_replicas, &update.last_known_elr)
1028 {
1029 self.apply_partition_elr(&PartitionElrRecord {
1030 topic: key.0.clone(),
1031 partition: key.1,
1032 eligible_leader_replicas: eligible.clone(),
1033 last_known_elr: last_known.clone(),
1034 });
1035 }
1036 if let Some(state) = update.recovery_state {
1037 self.apply_partition_recovery(&key.0, key.1, state);
1038 }
1039 }
1040
1041 fn apply_partition_elr(&mut self, record: &PartitionElrRecord) {
1042 if record.eligible_leader_replicas.is_empty() && record.last_known_elr.is_empty() {
1043 if let Some(partitions) = self.partition_elr.get_mut(&record.topic) {
1044 partitions.remove(&record.partition);
1045 if partitions.is_empty() {
1046 self.partition_elr.remove(&record.topic);
1047 }
1048 }
1049 } else {
1050 self.partition_elr
1051 .entry(record.topic.clone())
1052 .or_default()
1053 .insert(
1054 record.partition,
1055 (
1056 record.eligible_leader_replicas.clone(),
1057 record.last_known_elr.clone(),
1058 ),
1059 );
1060 }
1061 }
1062
1063 fn apply_partition_recovery(
1064 &mut self,
1065 topic: &str,
1066 partition: i32,
1067 state: LeaderRecoveryState,
1068 ) {
1069 if state == LeaderRecoveryState::Recovered {
1070 if let Some(partitions) = self.partition_recovery.get_mut(topic) {
1071 partitions.remove(&partition);
1072 if partitions.is_empty() {
1073 self.partition_recovery.remove(topic);
1074 }
1075 }
1076 } else {
1077 self.partition_recovery
1078 .entry(topic.to_owned())
1079 .or_default()
1080 .insert(partition, state);
1081 }
1082 }
1083
1084 fn apply_topic_freeze(&mut self, rec: &TopicFreezeRecord) {
1091 match (rec.pattern_type, rec.frozen) {
1092 (PatternType::Literal, true) => {
1093 self.freezes_literal.insert(rec.scope.clone(), rec.clone());
1094 }
1095 (PatternType::Literal, false) => {
1096 self.freezes_literal.remove(&rec.scope);
1097 }
1098 (PatternType::Prefixed, true) => {
1099 self.freezes_prefixed.insert(rec.scope.clone(), rec.clone());
1100 }
1101 (PatternType::Prefixed, false) => {
1102 self.freezes_prefixed.remove(&rec.scope);
1103 }
1104 }
1105 }
1106
1107 #[tracing::instrument(
1125 level = "debug",
1126 skip_all,
1127 fields(topics = self.topics.len(), partitions = self.total_partitions())
1128 )]
1129 #[must_use]
1130 pub fn to_records(&self) -> Vec<MetadataRecord> {
1131 let mut out = Vec::new();
1132
1133 for b in self.brokers.values() {
1135 out.push(MetadataRecord::V1BrokerRegistration(b.clone()));
1136 }
1137 for controller in self.controllers.values() {
1138 out.push(MetadataRecord::V1ControllerRegistration(controller.clone()));
1139 }
1140 for (node_id, configs) in &self.broker_configs {
1141 for (config_name, config_value) in configs {
1142 out.push(MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
1143 node_id: *node_id,
1144 config_name: config_name.clone(),
1145 config_value: Some(config_value.clone()),
1146 }));
1147 }
1148 }
1149
1150 for t in self.topics.values() {
1154 out.push(MetadataRecord::V1Topic(t.clone()));
1155 }
1156 for p in self.all_partitions() {
1157 out.push(MetadataRecord::V1Partition(p.clone()));
1158 }
1159 self.push_partition_offset_advances(&mut out);
1160 for (topic, partition, (eligible, last_known)) in
1161 self.partition_elr.iter().flat_map(|(topic, partitions)| {
1162 partitions
1163 .iter()
1164 .map(move |(partition, value)| (topic, partition, value))
1165 })
1166 {
1167 out.push(MetadataRecord::V1PartitionElr(PartitionElrRecord {
1168 topic: topic.clone(),
1169 partition: *partition,
1170 eligible_leader_replicas: eligible.clone(),
1171 last_known_elr: last_known.clone(),
1172 }));
1173 }
1174 for (topic, partition, state) in
1175 self.partition_recovery
1176 .iter()
1177 .flat_map(|(topic, partitions)| {
1178 partitions
1179 .iter()
1180 .map(move |(partition, value)| (topic, partition, value))
1181 })
1182 {
1183 out.push(MetadataRecord::V1PartitionRecovery(
1184 PartitionRecoveryRecord {
1185 topic: topic.clone(),
1186 partition: *partition,
1187 state: *state,
1188 },
1189 ));
1190 }
1191 for (topic, overrides) in &self.topic_configs {
1192 out.push(MetadataRecord::V1TopicConfig(TopicConfigRecord {
1193 topic: topic.clone(),
1194 overrides: overrides.clone(),
1195 }));
1196 }
1197
1198 for ((user, mechanism), cred) in &self.scram_credentials {
1200 out.push(MetadataRecord::V1ScramCredential(ScramCredentialRecord {
1201 user: user.clone(),
1202 mechanism: *mechanism,
1203 salt: cred.salt.clone(),
1204 stored_key: cred.stored_key.clone(),
1205 server_key: cred.server_key.clone(),
1206 iterations: cred.iterations,
1207 }));
1208 }
1209
1210 for entry in self.all_acls() {
1214 out.push(MetadataRecord::V1AccessControlEntry(entry.clone()));
1215 }
1216
1217 for (entity_key, configs) in &self.client_quotas {
1221 let entity: Vec<QuotaEntity> = entity_key
1222 .iter()
1223 .map(|(entity_type, entity_name)| QuotaEntity {
1224 entity_type: entity_type.clone(),
1225 entity_name: entity_name.clone(),
1226 })
1227 .collect();
1228 for (config_key, config_value) in configs {
1229 out.push(MetadataRecord::V1ClientQuota(ClientQuotaRecord {
1230 entity: entity.clone(),
1231 config_key: config_key.clone(),
1232 config_value: Some(*config_value),
1233 }));
1234 }
1235 }
1236
1237 if let Some(record) = self.producer_ids {
1238 out.push(MetadataRecord::V1ProducerIds(record));
1239 }
1240
1241 for tok in self.delegation_tokens.values() {
1243 out.push(MetadataRecord::V1DelegationToken(DelegationTokenRecord {
1244 token_id: tok.token_id.clone(),
1245 owner: tok.owner.clone(),
1246 requester: tok.requester.clone(),
1247 issue_timestamp_ms: tok.issue_timestamp_ms,
1248 expiry_timestamp_ms: tok.expiry_timestamp_ms,
1249 max_timestamp_ms: tok.max_timestamp_ms,
1250 renewers: tok.renewers.clone(),
1251 }));
1252 }
1253
1254 for (name, configs) in &self.client_metrics_configs {
1256 out.push(MetadataRecord::V1ClientMetricsConfig(
1257 ClientMetricsConfigRecord {
1258 name: name.clone(),
1259 configs: configs.clone(),
1260 },
1261 ));
1262 }
1263
1264 for (group_id, configs) in &self.group_configs {
1266 out.push(MetadataRecord::V1GroupConfig(GroupConfigRecord {
1267 group_id: group_id.clone(),
1268 configs: configs.clone(),
1269 }));
1270 }
1271
1272 for freeze in self.topic_freezes() {
1278 out.push(MetadataRecord::V1TopicFreeze(freeze.clone()));
1279 }
1280 for proposal in self.break_glass.values() {
1281 out.push(MetadataRecord::V1BreakGlassProposal(proposal.clone()));
1282 }
1283
1284 for (name, level) in &self.feature_levels {
1294 out.push(MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1295 name: name.clone(),
1296 level: *level,
1297 }));
1298 }
1299 if self.features_epoch >= 0 {
1300 out.push(MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord {
1301 epoch: self.features_epoch,
1302 }));
1303 }
1304
1305 if self.kraft_version != 0 {
1320 out.push(MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
1321 kraft_version: self.kraft_version,
1322 }));
1323 }
1324 if !self.voters.is_empty() {
1325 out.push(MetadataRecord::V1Voters(VotersRecord {
1326 voters: self.voters.clone(),
1327 }));
1328 }
1329
1330 out
1331 }
1332
1333 fn push_partition_offset_advances(&self, out: &mut Vec<MetadataRecord>) {
1334 for (topic, partition, count) in
1335 self.partition_next_offsets
1336 .iter()
1337 .flat_map(|(topic, partitions)| {
1338 partitions
1339 .iter()
1340 .map(move |(partition, value)| (topic, partition, value))
1341 })
1342 {
1343 out.push(MetadataRecord::V1PartitionOffsetAdvance(
1344 PartitionOffsetAdvanceRecord {
1345 topic: topic.clone(),
1346 partition: *partition,
1347 count: *count,
1348 },
1349 ));
1350 }
1351 }
1352
1353 #[tracing::instrument(level = "info", skip_all, fields(records = records.len()))]
1357 #[must_use]
1358 pub fn from_records(cluster_id: Uuid, records: &[MetadataRecord]) -> Self {
1359 let mut image = Self::new(cluster_id);
1360 for rec in records {
1361 image.apply(rec);
1362 }
1363 image
1364 }
1365
1366 #[tracing::instrument(level = "debug", skip_all, fields(record = record_variant(rec)), err)]
1373 pub fn validate(&self, rec: &MetadataRecord) -> Result<(), MetadataError> {
1374 match rec {
1375 MetadataRecord::V1Topic(t) => {
1376 if let Some(existing) = self.topics.get(&t.name) {
1377 if existing.topic_id != t.topic_id
1388 || existing.replication_factor != t.replication_factor
1389 || t.partitions <= self.topic_partition_count(&t.name)
1390 {
1391 return Err(MetadataError::TopicExists(t.name.clone()));
1392 }
1393 return Ok(());
1394 }
1395 Ok(())
1402 }
1403 MetadataRecord::V1Partition(p) => {
1404 if !self.topics.contains_key(&p.topic) {
1405 return Err(MetadataError::UnknownTopic(p.topic.clone()));
1406 }
1407 Ok(())
1408 }
1409 MetadataRecord::V1PartitionUpdate(update) => {
1410 if !self.topics.contains_key(&update.partition.topic) {
1411 return Err(MetadataError::UnknownTopic(update.partition.topic.clone()));
1412 }
1413 Ok(())
1414 }
1415 MetadataRecord::V1PartitionElr(record) => {
1416 if self.partition(&record.topic, record.partition).is_none() {
1417 return Err(MetadataError::UnknownTopic(record.topic.clone()));
1418 }
1419 Ok(())
1420 }
1421 MetadataRecord::V1PartitionRecovery(record) => {
1422 if self.partition(&record.topic, record.partition).is_none() {
1423 return Err(MetadataError::UnknownTopic(record.topic.clone()));
1424 }
1425 Ok(())
1426 }
1427 MetadataRecord::V1DeleteTopic(d) => {
1428 if !self.topics.contains_key(&d.name) {
1429 return Err(MetadataError::UnknownTopic(d.name.clone()));
1430 }
1431 Ok(())
1432 }
1433 MetadataRecord::V1TopicConfig(c) => {
1434 if !self.topics.contains_key(&c.topic) {
1435 return Err(MetadataError::UnknownTopic(c.topic.clone()));
1436 }
1437 Ok(())
1438 }
1439 MetadataRecord::V1BrokerRegistrationChange(change) => {
1445 if self.broker_epoch(change.node_id) != Some(change.broker_epoch) {
1446 return Err(MetadataError::InvalidRecord(
1447 "broker registration change for an epoch the broker is not registered at",
1448 ));
1449 }
1450 Ok(())
1451 }
1452 MetadataRecord::V1UnregisterBroker(rec) => {
1453 if self.broker_epoch(rec.node_id) != Some(rec.broker_epoch) {
1454 return Err(MetadataError::InvalidRecord(
1455 "broker unregistration for an epoch the broker is not registered at",
1456 ));
1457 }
1458 Ok(())
1459 }
1460 MetadataRecord::V1UnregisterController(rec) => {
1461 if !self.controllers.contains_key(&rec.node_id) {
1462 return Err(MetadataError::InvalidRecord(
1463 "controller unregistration for a controller that is not registered",
1464 ));
1465 }
1466 Ok(())
1467 }
1468 MetadataRecord::V1BrokerRegistration(_)
1469 | MetadataRecord::V1ControllerRegistration(_)
1470 | MetadataRecord::V1ScramCredential(_)
1471 | MetadataRecord::V1DeleteScramCredential(_)
1472 | MetadataRecord::V1AccessControlEntry(_)
1473 | MetadataRecord::V1DeleteAccessControlEntry(_)
1474 | MetadataRecord::V1BrokerConfig(_)
1475 | MetadataRecord::V1ClientQuota(_)
1476 | MetadataRecord::V1DelegationToken(_)
1481 | MetadataRecord::V1DeleteDelegationToken(_)
1482 | MetadataRecord::V1KRaftVersion(_)
1486 | MetadataRecord::V1Voters(_)
1487 | MetadataRecord::V1FeatureLevel(_)
1491 | MetadataRecord::V1ClientMetricsConfig(_)
1496 | MetadataRecord::V1GroupConfig(_)
1499 | MetadataRecord::V1FeaturesEpoch(_)
1503 | MetadataRecord::V1PartitionDirAssignment(_)
1508 | MetadataRecord::V1PartitionOffsetAdvance(_)
1511 | MetadataRecord::V1DeleteBreakGlassProposal(_) => Ok(()),
1514 MetadataRecord::V1TopicFreeze(freeze) => {
1520 if freeze.scope.is_empty() {
1521 return Err(MetadataError::EmptyFreezeScope);
1522 }
1523 Ok(())
1524 }
1525 MetadataRecord::V1BreakGlassProposal(proposal) => {
1533 let Some(stored) = self.break_glass.get(&proposal.proposal_id) else {
1534 return Ok(());
1535 };
1536 if stored.consumed_at_ms != 0 {
1537 return Err(MetadataError::BreakGlassProposalConsumed(
1538 proposal.proposal_id,
1539 ));
1540 }
1541 if stored.withdrawn {
1542 return Err(MetadataError::BreakGlassProposalWithdrawn(
1543 proposal.proposal_id,
1544 ));
1545 }
1546 if !extends(&stored.approvals, &proposal.approvals) {
1547 return Err(MetadataError::BreakGlassApprovalsNotAnExtension(
1548 proposal.proposal_id,
1549 ));
1550 }
1551 Ok(())
1552 }
1553 MetadataRecord::V1ProducerIds(record) => {
1554 if record.next_producer_id <= self.next_producer_id() {
1555 return Err(MetadataError::InvalidRecord(
1556 "next producer ID must advance monotonically",
1557 ));
1558 }
1559 if self.broker_epoch(record.broker_id) != Some(record.broker_epoch) {
1560 return Err(MetadataError::InvalidRecord(
1561 "producer ID allocation has a stale broker epoch",
1562 ));
1563 }
1564 Ok(())
1565 }
1566 }
1567 }
1568}
1569
1570fn is_cidr_acl_host(host: &str) -> bool {
1574 host.contains('/')
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use krabka_units::bytes_per_sec;
1580
1581 use super::*;
1582
1583 #[test]
1592 fn group_config_and_controller_readers_report_applied_records() {
1593 let mut image = MetadataImage::new(Uuid::nil());
1594 image.apply(&MetadataRecord::V1GroupConfig(GroupConfigRecord {
1595 group_id: "streams-app".into(),
1596 configs: BTreeMap::from([("streams.num.standby.replicas".into(), "1".into())]),
1597 }));
1598 image.apply(&MetadataRecord::V1ControllerRegistration(
1599 ControllerRegistrationRecord {
1600 node_id: NodeId(3),
1601 incarnation_id: Uuid::from_u128(7),
1602 zk_migration_ready: false,
1603 endpoints: Vec::new(),
1604 features: BTreeMap::new(),
1605 },
1606 ));
1607
1608 let groups: Vec<_> = image
1609 .group_configs()
1610 .map(|(id, cfg)| (id.clone(), cfg.clone()))
1611 .collect();
1612 check!(
1613 groups
1614 == vec![(
1615 "streams-app".to_owned(),
1616 BTreeMap::from([("streams.num.standby.replicas".to_owned(), "1".to_owned())])
1617 )]
1618 );
1619
1620 let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1621 check!(ids == vec![NodeId(3)]);
1622
1623 check!(image.controller(NodeId(3)).map(|c| c.incarnation_id) == Some(Uuid::from_u128(7)));
1626 check!(image.controller(NodeId(99)).is_none());
1627 }
1628
1629 fn registered_broker(node_id: u64, broker_epoch: i64) -> BrokerRegistrationRecord {
1630 BrokerRegistrationRecord {
1631 node_id: NodeId(node_id),
1632 broker_epoch,
1633 incarnation_id: Uuid::from_u128(u128::from(node_id)),
1634 host: format!("broker-{node_id}"),
1635 port: 9092,
1636 rack: None,
1637 endpoints: vec![],
1638 log_dirs: vec![],
1639 fenced: true,
1640 in_controlled_shutdown: false,
1641 cordoned_log_dirs: None,
1642 features: BTreeMap::new(),
1643 }
1644 }
1645
1646 #[test]
1650 fn registration_change_and_unregister_apply_only_at_the_registered_epoch() {
1651 use crate::records::{
1652 BrokerRegistrationChangeRecord, FencingChange, UnregisterBrokerRecord,
1653 };
1654
1655 let registered = registered_broker(4, 12);
1656 let unfenced = BrokerRegistrationRecord {
1657 fenced: false,
1658 ..registered.clone()
1659 };
1660 let unfence = |node_id, broker_epoch| {
1661 MetadataRecord::V1BrokerRegistrationChange(BrokerRegistrationChangeRecord {
1662 fenced: FencingChange::Unfence,
1663 ..BrokerRegistrationChangeRecord::no_change(NodeId(node_id), broker_epoch)
1664 })
1665 };
1666 let unregister = |node_id, broker_epoch| {
1667 MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
1668 node_id: NodeId(node_id),
1669 broker_epoch,
1670 })
1671 };
1672 let stale_change = Err(MetadataError::InvalidRecord(
1673 "broker registration change for an epoch the broker is not registered at",
1674 ));
1675 let stale_unregister = Err(MetadataError::InvalidRecord(
1676 "broker unregistration for an epoch the broker is not registered at",
1677 ));
1678 for (case, record, want_validate, want_broker) in [
1679 (
1680 "unfence at the epoch",
1681 unfence(4, 12),
1682 Ok(()),
1683 Some(unfenced),
1684 ),
1685 (
1686 "unfence at a stale epoch",
1687 unfence(4, 11),
1688 stale_change.clone(),
1689 Some(registered.clone()),
1690 ),
1691 (
1692 "unfence an unknown broker",
1693 unfence(5, 12),
1694 stale_change,
1695 Some(registered.clone()),
1696 ),
1697 ("unregister at the epoch", unregister(4, 12), Ok(()), None),
1698 (
1699 "unregister at a stale epoch",
1700 unregister(4, 11),
1701 stale_unregister.clone(),
1702 Some(registered.clone()),
1703 ),
1704 (
1705 "unregister an unknown broker",
1706 unregister(5, 12),
1707 stale_unregister,
1708 Some(registered.clone()),
1709 ),
1710 ] {
1711 let mut image = MetadataImage::new(Uuid::nil());
1712 image.apply(&MetadataRecord::V1BrokerRegistration(registered.clone()));
1713 check!(image.validate(&record) == want_validate, "{case}");
1714 image.apply(&record);
1715 check!(image.broker(NodeId(4)).cloned() == want_broker, "{case}");
1716 }
1717 }
1718
1719 #[test]
1722 fn unregister_controller_removes_the_registration() {
1723 use crate::records::{ControllerRegistrationRecord, UnregisterControllerRecord};
1724
1725 let mut image = MetadataImage::new(Uuid::nil());
1726 for node_id in [3000, 3001] {
1727 image.apply(&MetadataRecord::V1ControllerRegistration(
1728 ControllerRegistrationRecord {
1729 node_id: NodeId(node_id),
1730 incarnation_id: Uuid::from_u128(u128::from(node_id)),
1731 zk_migration_ready: false,
1732 endpoints: Vec::new(),
1733 features: BTreeMap::new(),
1734 },
1735 ));
1736 }
1737 let unregister = |node_id| {
1738 MetadataRecord::V1UnregisterController(UnregisterControllerRecord {
1739 node_id: NodeId(node_id),
1740 })
1741 };
1742
1743 check!(image.validate(&unregister(3000)) == Ok(()));
1744 image.apply(&unregister(3000));
1745 let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1746 check!(ids == vec![NodeId(3001)]);
1747
1748 check!(
1749 image.validate(&unregister(3000))
1750 == Err(MetadataError::InvalidRecord(
1751 "controller unregistration for a controller that is not registered",
1752 ))
1753 );
1754 image.apply(&unregister(3000));
1755 let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1756 check!(ids == vec![NodeId(3001)]);
1757 }
1758
1759 #[test]
1764 fn deleting_a_topic_drops_only_its_own_partition_offsets() {
1765 let mut image = MetadataImage::new(Uuid::nil());
1766 for topic in ["orders", "payments"] {
1767 image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
1768 PartitionOffsetAdvanceRecord {
1769 topic: topic.into(),
1770 partition: 0,
1771 count: 5,
1772 },
1773 ));
1774 }
1775 check!(
1776 (
1777 image.partition_next_offset("orders", 0),
1778 image.partition_next_offset("payments", 0),
1779 ) == (Some(5), Some(5))
1780 );
1781
1782 image.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
1783 name: "orders".into(),
1784 }));
1785 check!(
1786 (
1787 image.partition_next_offset("orders", 0),
1788 image.partition_next_offset("payments", 0),
1789 ) == (None, Some(5))
1790 );
1791 }
1792
1793 fn auxiliary_records(topic: &str, partition: i32) -> [MetadataRecord; 3] {
1794 [
1795 MetadataRecord::V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord {
1796 topic: topic.into(),
1797 partition,
1798 count: 7,
1799 }),
1800 MetadataRecord::V1PartitionElr(PartitionElrRecord {
1801 topic: topic.into(),
1802 partition,
1803 eligible_leader_replicas: vec![NodeId(3), NodeId(1)],
1804 last_known_elr: vec![NodeId(2)],
1805 }),
1806 MetadataRecord::V1PartitionRecovery(PartitionRecoveryRecord {
1807 topic: topic.into(),
1808 partition,
1809 state: LeaderRecoveryState::Recovering,
1810 }),
1811 ]
1812 }
1813
1814 #[test]
1815 fn deleting_topics_cleans_orphan_auxiliary_state_and_preserves_snapshot_records() {
1816 use serde_wincode::SerdeCompat;
1817 use wincode::Serialize as _;
1818
1819 let mut image = MetadataImage::new(Uuid::nil());
1820 let mut expected = Vec::new();
1821 for topic in ["orders", "payments", "orphan"] {
1822 for partition in [7, 0, 2] {
1823 for record in auxiliary_records(topic, partition) {
1824 image.apply(&record);
1825 if topic == "payments" {
1826 expected.push(record);
1827 }
1828 }
1829 }
1830 }
1831 let original = image.clone();
1833 for topic in ["unknown", "orders", "orphan", "orders"] {
1834 image.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
1835 name: topic.into(),
1836 }));
1837 }
1838 let records = image.to_records();
1839 let bytes = |records: &[MetadataRecord]| {
1840 let mut encoded: Vec<_> = records
1841 .iter()
1842 .map(|record| <SerdeCompat<MetadataRecord>>::serialize(record).unwrap())
1843 .collect();
1844 encoded.sort();
1845 encoded
1846 };
1847 assert2::assert!(bytes(&records) == bytes(&expected));
1850 let kinds: Vec<_> = records
1851 .iter()
1852 .map(|record| match record {
1853 MetadataRecord::V1PartitionOffsetAdvance(_) => 0,
1854 MetadataRecord::V1PartitionElr(_) => 1,
1855 MetadataRecord::V1PartitionRecovery(_) => 2,
1856 _ => unreachable!(),
1857 })
1858 .collect();
1859 assert2::assert!(kinds == vec![0, 0, 0, 1, 1, 1, 2, 2, 2]);
1860 assert2::assert!(MetadataImage::from_records(Uuid::nil(), &records) == image);
1861 assert2::assert!(
1862 MetadataImage::from_records(Uuid::nil(), &original.to_records()) == original
1863 );
1864 for partition in [0, 2, 7] {
1865 assert2::assert!(image.partition_next_offset("orders", partition).is_none());
1866 assert2::assert!(image.partition_elr("orders", partition) == (&[][..], &[][..]));
1867 assert2::assert!(
1868 image.leader_recovery_state("orders", partition) == LeaderRecoveryState::Recovered
1869 );
1870 assert2::assert!(image.partition_next_offset("payments", partition) == Some(7));
1871 assert2::assert!(
1872 image.partition_elr("payments", partition)
1873 == (&[NodeId(3), NodeId(1)][..], &[NodeId(2)][..])
1874 );
1875 }
1876 for record in auxiliary_records("orders", 0) {
1878 image.apply(&record);
1879 }
1880 assert2::assert!(image.partition_next_offset("orders", 0) == Some(7));
1881 }
1882
1883 #[test]
1884 fn clearing_last_auxiliary_partition_leaves_no_empty_topic_groups() {
1885 let mut image = MetadataImage::new(Uuid::nil());
1886 for record in auxiliary_records("orders", 0) {
1887 image.apply(&record);
1888 }
1889 image.apply(&MetadataRecord::V1PartitionElr(PartitionElrRecord {
1890 topic: "orders".into(),
1891 partition: 0,
1892 eligible_leader_replicas: vec![],
1893 last_known_elr: vec![],
1894 }));
1895 image.apply(&MetadataRecord::V1PartitionRecovery(
1896 PartitionRecoveryRecord {
1897 topic: "orders".into(),
1898 partition: 0,
1899 state: LeaderRecoveryState::Recovered,
1900 },
1901 ));
1902 assert2::assert!(image.partition_elr.is_empty());
1903 assert2::assert!(image.partition_recovery.is_empty());
1904 assert2::assert!(MetadataImage::from_records(Uuid::nil(), &image.to_records()) == image);
1905 }
1906
1907 #[test]
1908 fn partition_updates_preserve_absent_fields_and_other_auxiliary_partitions() {
1909 use crate::PartitionUpdateRecord;
1910
1911 let mut image = MetadataImage::new(Uuid::nil());
1912 for partition in [0, 1] {
1913 for record in auxiliary_records("orders", partition) {
1914 image.apply(&record);
1915 }
1916 }
1917 let partition = PartitionRecord {
1918 topic: "orders".into(),
1919 partition: 0,
1920 ..PartitionRecord::default()
1921 };
1922 image.apply(&MetadataRecord::V1PartitionUpdate(PartitionUpdateRecord {
1923 partition: partition.clone(),
1924 eligible_leader_replicas: None,
1925 last_known_elr: None,
1926 recovery_state: None,
1927 }));
1928 assert2::assert!(
1929 image.partition_elr("orders", 0) == (&[NodeId(3), NodeId(1)][..], &[NodeId(2)][..])
1930 );
1931 assert2::assert!(
1932 image.leader_recovery_state("orders", 0) == LeaderRecoveryState::Recovering
1933 );
1934 image.apply(&MetadataRecord::V1PartitionUpdate(PartitionUpdateRecord {
1935 partition,
1936 eligible_leader_replicas: Some(vec![]),
1937 last_known_elr: Some(vec![]),
1938 recovery_state: Some(LeaderRecoveryState::Recovered),
1939 }));
1940 assert2::assert!(image.partition_elr("orders", 0) == (&[][..], &[][..]));
1941 assert2::assert!(
1942 image.leader_recovery_state("orders", 0) == LeaderRecoveryState::Recovered
1943 );
1944 assert2::assert!(
1945 image.partition_elr("orders", 1) == (&[NodeId(3), NodeId(1)][..], &[NodeId(2)][..])
1946 );
1947 assert2::assert!(
1948 image.leader_recovery_state("orders", 1) == LeaderRecoveryState::Recovering
1949 );
1950 assert2::assert!(image.partition_next_offset("orders", 0) == Some(7));
1951 assert2::assert!(MetadataImage::from_records(Uuid::nil(), &image.to_records()) == image);
1952 }
1953
1954 fn broker_at(node_id: NodeId, epoch: i64) -> MetadataRecord {
1955 MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
1956 node_id,
1957 broker_epoch: epoch,
1958 incarnation_id: Uuid::nil(),
1959 host: "h".into(),
1960 port: 9092,
1961 rack: None,
1962 log_dirs: vec![],
1963 fenced: false,
1964 in_controlled_shutdown: false,
1965 cordoned_log_dirs: None,
1966 endpoints: vec![],
1967 features: BTreeMap::new(),
1968 })
1969 }
1970
1971 #[test]
1977 fn producer_id_allocation_must_advance_and_carry_the_live_epoch() {
1978 let mut image = MetadataImage::new(Uuid::nil());
1979 image.apply(&broker_at(NodeId(1), 11));
1980 image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
1981 broker_id: NodeId(1),
1982 broker_epoch: 11,
1983 next_producer_id: 1000,
1984 }));
1985
1986 let allocation = |next: i64, epoch: i64| {
1987 MetadataRecord::V1ProducerIds(ProducerIdsRecord {
1988 broker_id: NodeId(1),
1989 broker_epoch: epoch,
1990 next_producer_id: next,
1991 })
1992 };
1993
1994 check!(image.validate(&allocation(2000, 11)).is_ok());
1995 check!(image.validate(&allocation(1000, 11)).is_err());
1997 check!(image.validate(&allocation(999, 11)).is_err());
1998 check!(image.validate(&allocation(2000, 10)).is_err());
2000 }
2001
2002 #[test]
2007 fn an_equal_producer_id_allocation_does_not_replace_the_recorded_one() {
2008 let mut image = MetadataImage::new(Uuid::nil());
2009 image.apply(&broker_at(NodeId(1), 11));
2010 image.apply(&broker_at(NodeId(2), 22));
2011 let original = ProducerIdsRecord {
2012 broker_id: NodeId(1),
2013 broker_epoch: 11,
2014 next_producer_id: 1000,
2015 };
2016 image.apply(&MetadataRecord::V1ProducerIds(original));
2017 image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
2018 broker_id: NodeId(2),
2019 broker_epoch: 22,
2020 next_producer_id: 1000,
2021 }));
2022
2023 let recorded: Vec<_> = image
2024 .to_records()
2025 .into_iter()
2026 .filter(|r| matches!(r, MetadataRecord::V1ProducerIds(_)))
2027 .collect();
2028 check!(recorded == vec![MetadataRecord::V1ProducerIds(original)]);
2029 }
2030
2031 #[test]
2032 fn record_variant_returns_exact_discriminant_names() {
2033 let topic = MetadataRecord::V1Topic(TopicRecord {
2034 name: "orders".into(),
2035 topic_id: Uuid::new_v4(),
2036 partitions: 3,
2037 replication_factor: 2,
2038 });
2039 assert2::assert!(record_variant(&topic) == "V1Topic");
2040
2041 let feature = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2042 name: "metadata.version".into(),
2043 level: 1,
2044 });
2045 assert2::assert!(record_variant(&feature) == "V1FeatureLevel");
2046 }
2047
2048 use assert2::check;
2049
2050 use crate::{
2051 acl::{AclEntryFilter, AclOperation, PermissionType},
2052 break_glass::BreakGlassAction,
2053 records::{
2054 BrokerConfigRecord, BrokerEndpoint, ClientQuotaRecord, DeleteDelegationTokenRecord,
2055 DeleteScramCredentialRecord, DeleteTopicRecord, FeatureLevelRecord, LeaderEpoch,
2056 PartitionOffsetAdvanceRecord, QuotaEntity, ScramCredentialRecord,
2057 },
2058 voters::{KRaftVersionRange, Voter},
2059 };
2060
2061 fn img() -> MetadataImage {
2062 MetadataImage::new(Uuid::nil())
2063 }
2064
2065 #[test]
2066 fn fresh_image_has_no_features_and_unknown_epoch() {
2067 let m = img();
2068 assert2::assert!(m.finalized_features().is_empty());
2069 assert2::assert!(m.finalized_features_epoch() == -1);
2070 }
2071
2072 #[test]
2073 fn finalized_feature_reads_arbitrary_name() {
2074 let mut m = MetadataImage::new(uuid::Uuid::nil());
2075 assert2::assert!(m.finalized_feature("transaction.version").is_none());
2076 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2077 name: "transaction.version".into(),
2078 level: 2,
2079 }));
2080 assert2::assert!(m.finalized_feature("transaction.version") == Some(2));
2081 }
2082
2083 #[test]
2084 fn apply_feature_level_sets_level_and_bumps_epoch() {
2085 let mut m = img();
2086 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2087 name: "metadata.version".into(),
2088 level: 1,
2089 }));
2090 assert2::assert!(m.finalized_features().get("metadata.version") == Some(&1));
2091 assert2::assert!(m.finalized_features_epoch() == 0);
2092
2093 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2095 name: "metadata.version".into(),
2096 level: 1,
2097 }));
2098 assert2::assert!(m.finalized_features_epoch() == 1);
2099 }
2100
2101 #[test]
2102 fn apply_feature_level_zero_deletes_entry() {
2103 let mut m = img();
2104 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2105 name: "metadata.version".into(),
2106 level: 1,
2107 }));
2108 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2109 name: "metadata.version".into(),
2110 level: 0,
2111 }));
2112 assert2::assert!(
2113 (
2114 m.finalized_features().get("metadata.version"),
2115 m.finalized_features_epoch(),
2116 ) == (None, 1)
2117 );
2118 }
2119
2120 #[test]
2121 fn to_records_from_records_round_trips() {
2122 let cid = Uuid::new_v4();
2123 let mut image = MetadataImage::new(cid);
2124 image.apply(&MetadataRecord::V1Topic(TopicRecord {
2125 name: "orders".into(),
2126 topic_id: Uuid::new_v4(),
2127 partitions: 3,
2128 replication_factor: 2,
2129 }));
2130 assert2::assert!(MetadataImage::from_records(cid, &image.to_records()) == image);
2131 }
2132
2133 #[test]
2134 fn offset_advance_applies_as_monotonic_delta() {
2135 let mut image = img();
2136 let advance = |count: i64| {
2137 MetadataRecord::V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord {
2138 topic: "t".into(),
2139 partition: 0,
2140 count,
2141 })
2142 };
2143
2144 image.apply(&advance(3));
2145 image.apply(&advance(2));
2146 image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
2147 PartitionOffsetAdvanceRecord {
2148 topic: "t".into(),
2149 partition: 1,
2150 count: 7,
2151 },
2152 ));
2153
2154 assert2::assert!(image.partition_next_offset("t", 0) == Some(5));
2155 assert2::assert!(image.partition_next_offset("t", 1) == Some(7));
2156 }
2157
2158 #[test]
2159 fn offset_advance_survives_snapshot_round_trip() {
2160 let mut image = img();
2161 image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
2162 PartitionOffsetAdvanceRecord {
2163 topic: "t".into(),
2164 partition: 0,
2165 count: 5,
2166 },
2167 ));
2168
2169 let restored = MetadataImage::from_records(Uuid::nil(), &image.to_records());
2170
2171 assert2::assert!(restored.partition_next_offset("t", 0) == Some(5));
2172 }
2173
2174 fn apply_broker_fixture(image: &mut MetadataImage) {
2175 for record in [
2176 MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2177 node_id: NodeId(1),
2178 broker_epoch: 0,
2179 incarnation_id: Uuid::nil(),
2180 host: "h1".into(),
2181 port: 9092,
2182 rack: Some("r1".into()),
2183 log_dirs: vec![],
2184 fenced: false,
2185 in_controlled_shutdown: false,
2186 cordoned_log_dirs: None,
2187 endpoints: vec![BrokerEndpoint {
2188 name: "EXTERNAL".into(),
2189 host: "ext".into(),
2190 port: 9093,
2191 protocol: krabka_security::ListenerProtocol::SaslSsl,
2192 }],
2193 features: BTreeMap::new(),
2194 }),
2195 MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2196 node_id: NodeId(2),
2197 broker_epoch: 0,
2198 incarnation_id: Uuid::nil(),
2199 host: "h2".into(),
2200 port: 9092,
2201 rack: None,
2202 log_dirs: vec![],
2203 fenced: false,
2204 in_controlled_shutdown: false,
2205 cordoned_log_dirs: None,
2206 endpoints: vec![],
2207 features: BTreeMap::new(),
2208 }),
2209 MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
2210 node_id: NodeId(2),
2211 broker_epoch: 0,
2212 }),
2213 MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2214 node_id: NodeId(1),
2215 config_name: "leader.replication.throttled.rate".into(),
2216 config_value: Some("2048".into()),
2217 }),
2218 MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2219 node_id: NodeId(1),
2220 config_name: "follower.replication.throttled.rate".into(),
2221 config_value: Some("4096".into()),
2222 }),
2223 MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2224 node_id: NodeId(1),
2225 config_name: "follower.replication.throttled.rate".into(),
2226 config_value: None,
2227 }),
2228 ] {
2229 image.apply(&record);
2230 }
2231 }
2232
2233 #[test]
2238 fn to_records_round_trips_all_variants() {
2240 use krabka_security::{KafkaPrincipal, SaslMechanism};
2241
2242 let cid = Uuid::new_v4();
2243 let mut image = MetadataImage::new(cid);
2244
2245 apply_broker_fixture(&mut image);
2248
2249 image.apply(&MetadataRecord::V1Topic(TopicRecord {
2252 name: "orders".into(),
2253 topic_id: Uuid::new_v4(),
2254 partitions: 2,
2255 replication_factor: 2,
2256 }));
2257 image.apply(&MetadataRecord::V1Topic(TopicRecord {
2258 name: "doomed".into(),
2259 topic_id: Uuid::new_v4(),
2260 partitions: 1,
2261 replication_factor: 1,
2262 }));
2263 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2264 topic: "orders".into(),
2265 partition: 0,
2266 leader: NodeId(1),
2267 replicas: vec![NodeId(1), NodeId(2)],
2268 isr: vec![NodeId(1), NodeId(2)],
2269 leader_epoch: LeaderEpoch(4),
2270 adding_replicas: vec![NodeId(2)],
2271 removing_replicas: vec![],
2272 directories: vec![],
2273 partition_epoch: 0,
2274 }));
2275 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2276 topic: "orders".into(),
2277 partition: 1,
2278 leader: NodeId(2),
2279 replicas: vec![NodeId(2), NodeId(1)],
2280 isr: vec![NodeId(2)],
2281 leader_epoch: LeaderEpoch(7),
2282 adding_replicas: vec![],
2283 removing_replicas: vec![NodeId(1)],
2284 directories: vec![],
2285 partition_epoch: 0,
2286 }));
2287 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2288 topic: "doomed".into(),
2289 partition: 0,
2290 leader: NodeId(1),
2291 replicas: vec![NodeId(1)],
2292 isr: vec![NodeId(1)],
2293 leader_epoch: LeaderEpoch(0),
2294 adding_replicas: vec![],
2295 removing_replicas: vec![],
2296 directories: vec![],
2297 partition_epoch: 0,
2298 }));
2299
2300 let mut overrides = BTreeMap::new();
2302 overrides.insert("retention.ms".to_owned(), "60000".to_owned());
2303 overrides.insert("segment.bytes".to_owned(), "1048576".to_owned());
2304 image.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2305 topic: "orders".into(),
2306 overrides,
2307 }));
2308
2309 image.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2311 name: "doomed".into(),
2312 }));
2313
2314 image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2316 user: "alice".into(),
2317 mechanism: SaslMechanism::ScramSha512,
2318 salt: vec![1; 16],
2319 stored_key: vec![2; 64],
2320 server_key: vec![3; 64],
2321 iterations: 8192,
2322 }));
2323 image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2324 user: "bob".into(),
2325 mechanism: SaslMechanism::ScramSha256,
2326 salt: vec![4; 16],
2327 stored_key: vec![5; 32],
2328 server_key: vec![6; 32],
2329 iterations: 4096,
2330 }));
2331 image.apply(&MetadataRecord::V1DeleteScramCredential(
2332 DeleteScramCredentialRecord {
2333 user: "bob".into(),
2334 mechanism: SaslMechanism::ScramSha256,
2335 },
2336 ));
2337
2338 let literal = AclEntry {
2340 resource_type: ResourceType::Topic,
2341 resource_name: "orders".into(),
2342 pattern_type: PatternType::Literal,
2343 principal: "User:alice".into(),
2344 host: "*".into(),
2345 operation: AclOperation::Read,
2346 permission_type: PermissionType::Allow,
2347 };
2348 let prefixed = AclEntry {
2349 resource_type: ResourceType::Group,
2350 resource_name: "cg-".into(),
2351 pattern_type: PatternType::Prefixed,
2352 principal: "User:alice".into(),
2353 host: "*".into(),
2354 operation: AclOperation::Read,
2355 permission_type: PermissionType::Allow,
2356 };
2357 let doomed_acl = AclEntry {
2358 resource_type: ResourceType::Cluster,
2359 resource_name: "kafka-cluster".into(),
2360 pattern_type: PatternType::Literal,
2361 principal: "User:eve".into(),
2362 host: "*".into(),
2363 operation: AclOperation::Alter,
2364 permission_type: PermissionType::Deny,
2365 };
2366 image.apply(&MetadataRecord::V1AccessControlEntry(literal));
2367 image.apply(&MetadataRecord::V1AccessControlEntry(prefixed));
2368 image.apply(&MetadataRecord::V1AccessControlEntry(doomed_acl));
2369 image.apply(&MetadataRecord::V1DeleteAccessControlEntry(
2370 AclEntryFilter {
2371 resource_type: Some(ResourceType::Cluster),
2372 ..AclEntryFilter::default()
2373 },
2374 ));
2375
2376 image.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
2378 entity: vec![
2379 QuotaEntity {
2380 entity_type: "user".into(),
2381 entity_name: Some("alice".into()),
2382 },
2383 QuotaEntity {
2384 entity_type: "client-id".into(),
2385 entity_name: Some("app1".into()),
2386 },
2387 ],
2388 config_key: "producer_byte_rate".into(),
2389 config_value: Some(1024.0),
2390 }));
2391 image.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
2392 entity: vec![QuotaEntity {
2393 entity_type: "user".into(),
2394 entity_name: None,
2395 }],
2396 config_key: "consumer_byte_rate".into(),
2397 config_value: Some(2048.0),
2398 }));
2399
2400 let alice = KafkaPrincipal {
2402 principal_type: "User".into(),
2403 name: "alice".into(),
2404 };
2405 let bob = KafkaPrincipal {
2406 principal_type: "User".into(),
2407 name: "bob".into(),
2408 };
2409 image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
2410 token_id: "tok-1".into(),
2411 owner: alice.clone(),
2412 requester: alice,
2413 issue_timestamp_ms: 1_000,
2414 expiry_timestamp_ms: 5_000,
2415 max_timestamp_ms: 10_000,
2416 renewers: vec![bob.clone()],
2417 }));
2418 image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
2419 token_id: "tok-2".into(),
2420 owner: bob.clone(),
2421 requester: bob,
2422 issue_timestamp_ms: 1_000,
2423 expiry_timestamp_ms: 5_000,
2424 max_timestamp_ms: 10_000,
2425 renewers: vec![],
2426 }));
2427 image.apply(&MetadataRecord::V1DeleteDelegationToken(
2428 DeleteDelegationTokenRecord {
2429 token_id: "tok-2".into(),
2430 },
2431 ));
2432
2433 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2437 name: "metadata.version".into(),
2438 level: 24,
2439 }));
2440 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2441 name: "metadata.version".into(),
2442 level: 25,
2443 }));
2444 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2445 name: "group.version".into(),
2446 level: 1,
2447 }));
2448 assert2::assert!(image.finalized_features_epoch() == 2);
2449
2450 let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2451 assert2::assert!(rebuilt == image);
2452 }
2453
2454 #[test]
2462 fn to_records_round_trips_features_and_epoch() {
2463 let cid = Uuid::new_v4();
2464 let mut image = MetadataImage::new(cid);
2465 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2468 name: "metadata.version".into(),
2469 level: 24,
2470 }));
2471 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2472 name: "metadata.version".into(),
2473 level: 25,
2474 }));
2475 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2476 name: "group.version".into(),
2477 level: 1,
2478 }));
2479 image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2480 name: "metadata.version".into(),
2481 level: 25,
2482 }));
2483 assert2::assert!(image.finalized_features_epoch() == 3);
2484
2485 let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2486 assert2::assert!(rebuilt == image);
2487 check!(
2490 (
2491 rebuilt.finalized_features().get("metadata.version"),
2492 rebuilt.finalized_features().get("group.version"),
2493 rebuilt.finalized_features_epoch(),
2494 ) == (Some(&25), Some(&1), 3)
2495 );
2496 }
2497
2498 #[test]
2506 fn to_records_preserves_voters_and_kraft_version() {
2507 use crate::{
2508 records::{KRaftVersionRecord, VotersRecord},
2509 voters::{KRaftVersionRange, Voter, VoterEndpoint, VoterSet},
2510 };
2511
2512 let cid = Uuid::new_v4();
2513 let mut image = MetadataImage::new(cid);
2514 image.apply(&MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
2515 kraft_version: 1,
2516 }));
2517 let voters = VoterSet::from_voters([
2518 Voter {
2519 id: NodeId(1),
2520 directory_id: Uuid::from_u128(1),
2521 endpoints: vec![VoterEndpoint {
2522 name: "CONTROLLER".into(),
2523 host: "127.0.0.1".into(),
2524 port: 9093,
2525 }],
2526 kraft_version: KRaftVersionRange::default(),
2527 },
2528 Voter {
2529 id: NodeId(2),
2530 directory_id: Uuid::from_u128(2),
2531 endpoints: vec![VoterEndpoint {
2532 name: "CONTROLLER".into(),
2533 host: "127.0.0.1".into(),
2534 port: 9094,
2535 }],
2536 kraft_version: KRaftVersionRange { min: 0, max: 1 },
2537 },
2538 ]);
2539 image.apply(&MetadataRecord::V1Voters(VotersRecord { voters }));
2540
2541 let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2542
2543 assert2::assert!(rebuilt == image);
2544 }
2545
2546 fn topic(name: &str, partitions: i32) -> MetadataRecord {
2547 MetadataRecord::V1Topic(TopicRecord {
2548 name: name.into(),
2549 topic_id: Uuid::new_v4(),
2550 partitions,
2551 replication_factor: 1,
2552 })
2553 }
2554
2555 #[test]
2556 fn apply_topic_inserts() {
2557 let mut m = img();
2558 m.apply(&topic("t", 3));
2559 assert2::assert!(m.topic("t").is_some());
2560 }
2561
2562 #[test]
2563 fn apply_dir_assignment_merges_one_slot_without_clobbering_reassignment() {
2564 let mut m = img();
2568 m.apply(&topic("t", 1));
2569 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2570 topic: "t".into(),
2571 partition: 0,
2572 leader: NodeId(1),
2573 replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
2574 isr: vec![NodeId(1), NodeId(2)],
2575 leader_epoch: LeaderEpoch(0),
2576 adding_replicas: vec![NodeId(3)],
2577 removing_replicas: vec![],
2578 directories: vec![uuid::Uuid::nil(), uuid::Uuid::nil(), uuid::Uuid::nil()],
2579 partition_epoch: 0,
2580 }));
2581 let dir = uuid::Uuid::from_u128(0xABCD);
2582 m.apply(&MetadataRecord::V1PartitionDirAssignment(
2583 PartitionDirAssignmentRecord {
2584 topic: "t".into(),
2585 partition: 0,
2586 replica: NodeId(2),
2587 directory: dir,
2588 },
2589 ));
2590 let pr = m.partition("t", 0).unwrap();
2591 assert2::assert!(
2594 *pr == PartitionRecord {
2595 topic: "t".to_owned(),
2596 partition: 0,
2597 leader: NodeId(1),
2598 replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
2599 isr: vec![NodeId(1), NodeId(2)],
2600 leader_epoch: krabka_ids::LeaderEpoch(0),
2601 adding_replicas: vec![NodeId(3)],
2602 removing_replicas: vec![],
2603 directories: vec![uuid::Uuid::nil(), dir, uuid::Uuid::nil()],
2604 partition_epoch: 0,
2605 }
2606 );
2607 }
2608
2609 #[test]
2610 fn apply_delete_clears_partitions() {
2611 let mut m = img();
2612 m.apply(&topic("t", 2));
2613 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2614 topic: "t".into(),
2615 partition: 0,
2616 leader: NodeId(1),
2617 replicas: vec![NodeId(1)],
2618 isr: vec![NodeId(1)],
2619 leader_epoch: LeaderEpoch(0),
2620 adding_replicas: vec![],
2621 removing_replicas: vec![],
2622 directories: vec![],
2623 partition_epoch: 0,
2624 }));
2625 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2626 topic: "t".into(),
2627 partition: 1,
2628 leader: NodeId(1),
2629 replicas: vec![NodeId(1)],
2630 isr: vec![NodeId(1)],
2631 leader_epoch: LeaderEpoch(0),
2632 adding_replicas: vec![],
2633 removing_replicas: vec![],
2634 directories: vec![],
2635 partition_epoch: 0,
2636 }));
2637 assert2::assert!(m.partitions_of("t").count() == 2);
2638 m.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2639 name: "t".into(),
2640 }));
2641 assert2::assert!(m.topic("t").is_none());
2642 assert2::assert!(m.partitions_of("t").count() == 0);
2643 }
2644
2645 #[test]
2646 fn validate_topic_exists_rejected() {
2647 let mut m = img();
2648 m.apply(&topic("t", 1));
2649 let err = m.validate(&topic("t", 1)).unwrap_err();
2650 assert2::assert!(matches!(err, MetadataError::TopicExists(_)));
2651 }
2652
2653 fn apply_partitions(m: &mut MetadataImage, topic: &str, count: i32) {
2658 for p in 0..count {
2659 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2660 topic: topic.into(),
2661 partition: p,
2662 leader: NodeId(1),
2663 replicas: vec![NodeId(1)],
2664 isr: vec![NodeId(1)],
2665 leader_epoch: LeaderEpoch(0),
2666 adding_replicas: vec![],
2667 removing_replicas: vec![],
2668 directories: vec![],
2669 partition_epoch: 0,
2670 }));
2671 }
2672 }
2673
2674 #[test]
2675 fn validate_topic_partition_count_increase_allowed() {
2676 let mut m = img();
2677 m.apply(&topic("t", 1));
2678 apply_partitions(&mut m, "t", 1);
2679 let existing = m.topic("t").unwrap().clone();
2680 let updated = MetadataRecord::V1Topic(TopicRecord {
2681 name: "t".into(),
2682 topic_id: existing.topic_id,
2683 partitions: 3,
2684 replication_factor: existing.replication_factor,
2685 });
2686 assert2::assert!(m.validate(&updated).is_ok());
2687 }
2688
2689 #[test]
2690 fn validate_topic_partition_count_decrease_rejected() {
2691 let mut m = img();
2692 m.apply(&topic("t", 3));
2693 apply_partitions(&mut m, "t", 3);
2694 let existing = m.topic("t").unwrap().clone();
2695 let updated = MetadataRecord::V1Topic(TopicRecord {
2696 name: "t".into(),
2697 topic_id: existing.topic_id,
2698 partitions: 1,
2699 replication_factor: existing.replication_factor,
2700 });
2701 let err = m.validate(&updated).unwrap_err();
2702 assert2::assert!(matches!(err, MetadataError::TopicExists(_)));
2703 }
2704
2705 #[test]
2706 fn validate_delete_unknown_topic_rejected() {
2707 let m = img();
2708 let err = m
2709 .validate(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2710 name: "ghost".into(),
2711 }))
2712 .unwrap_err();
2713 assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2714 }
2715
2716 #[test]
2717 fn validate_partition_for_unknown_topic_rejected() {
2718 let m = img();
2719 let p = MetadataRecord::V1Partition(PartitionRecord {
2720 topic: "ghost".into(),
2721 partition: 0,
2722 leader: NodeId(1),
2723 replicas: vec![NodeId(1)],
2724 isr: vec![NodeId(1)],
2725 leader_epoch: LeaderEpoch(0),
2726 adding_replicas: vec![],
2727 removing_replicas: vec![],
2728 directories: vec![],
2729 partition_epoch: 0,
2730 });
2731 let err = m.validate(&p).unwrap_err();
2732 assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2733 }
2734
2735 #[test]
2736 fn broker_registration_is_idempotent() {
2737 let mut m = img();
2738 let b = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2739 node_id: NodeId(1),
2740 broker_epoch: 0,
2741 incarnation_id: Uuid::nil(),
2742 host: "h".into(),
2743 port: 9092,
2744 rack: None,
2745 log_dirs: vec![],
2746 fenced: false,
2747 in_controlled_shutdown: false,
2748 cordoned_log_dirs: None,
2749 endpoints: vec![],
2750 features: BTreeMap::new(),
2751 });
2752 m.apply(&b);
2753 m.apply(&b);
2754 assert2::assert!(m.brokers().count() == 1);
2755 }
2756
2757 #[test]
2758 fn apply_topic_config_inserts() {
2759 let mut m = img();
2760 m.apply(&topic("t", 1));
2761 let mut overrides = BTreeMap::new();
2762 overrides.insert("retention.ms".to_owned(), "60000".to_owned());
2763 m.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2764 topic: "t".into(),
2765 overrides: overrides.clone(),
2766 }));
2767 assert2::assert!(m.topic_config("t") == Some(&overrides));
2768 }
2769
2770 #[test]
2771 fn apply_topic_config_replaces_previous() {
2772 let mut m = img();
2773 m.apply(&topic("t", 1));
2774
2775 let mut first = BTreeMap::new();
2776 first.insert("retention.ms".to_owned(), "60000".to_owned());
2777 first.insert("segment.bytes".to_owned(), "1024".to_owned());
2778 m.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2779 topic: "t".into(),
2780 overrides: first,
2781 }));
2782
2783 let mut second = BTreeMap::new();
2784 second.insert("retention.ms".to_owned(), "120000".to_owned());
2785 m.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2786 topic: "t".into(),
2787 overrides: second.clone(),
2788 }));
2789
2790 assert2::assert!(m.topic_config("t") == Some(&second));
2792 }
2793
2794 #[test]
2795 fn delete_topic_clears_configs() {
2796 let mut m = img();
2797 m.apply(&topic("t", 1));
2798 let mut overrides = BTreeMap::new();
2799 overrides.insert("retention.ms".to_owned(), "60000".to_owned());
2800 m.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2801 topic: "t".into(),
2802 overrides,
2803 }));
2804 m.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2805 name: "t".into(),
2806 }));
2807 assert2::assert!(m.topic_config("t").is_none());
2808 }
2809
2810 #[test]
2811 fn validate_topic_config_for_unknown_topic_rejected() {
2812 let m = img();
2813 let r = MetadataRecord::V1TopicConfig(TopicConfigRecord {
2814 topic: "ghost".into(),
2815 overrides: BTreeMap::new(),
2816 });
2817 let err = m.validate(&r).unwrap_err();
2818 assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2819 }
2820
2821 #[test]
2822 fn apply_scram_credential_stores() {
2823 let mut m = img();
2824 m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2825 user: "alice".into(),
2826 mechanism: krabka_security::SaslMechanism::ScramSha512,
2827 salt: vec![1; 16],
2828 stored_key: vec![2; 64],
2829 server_key: vec![3; 64],
2830 iterations: 4096,
2831 }));
2832 let got = m.scram_credential("alice", krabka_security::SaslMechanism::ScramSha512);
2833 assert2::assert!(got.map(|credential| credential.iterations) == Some(4096));
2834 }
2835
2836 #[test]
2837 fn apply_scram_credential_last_write_wins() {
2838 let mut m = img();
2839 let mech = krabka_security::SaslMechanism::ScramSha512;
2840 m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2841 user: "alice".into(),
2842 mechanism: mech,
2843 salt: vec![1; 16],
2844 stored_key: vec![2; 64],
2845 server_key: vec![3; 64],
2846 iterations: 4096,
2847 }));
2848 m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2849 user: "alice".into(),
2850 mechanism: mech,
2851 salt: vec![9; 16],
2852 stored_key: vec![9; 64],
2853 server_key: vec![9; 64],
2854 iterations: 8192,
2855 }));
2856 let got = m.scram_credential("alice", mech).unwrap();
2857 assert2::assert!((got.iterations, &got.salt) == (8192, &vec![9; 16]));
2858 }
2859
2860 #[test]
2861 fn delete_scram_credential_removes() {
2862 let mut m = img();
2863 let mech = krabka_security::SaslMechanism::ScramSha512;
2864 m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2865 user: "alice".into(),
2866 mechanism: mech,
2867 salt: vec![1; 16],
2868 stored_key: vec![2; 64],
2869 server_key: vec![3; 64],
2870 iterations: 4096,
2871 }));
2872 m.apply(&MetadataRecord::V1DeleteScramCredential(
2873 DeleteScramCredentialRecord {
2874 user: "alice".into(),
2875 mechanism: mech,
2876 },
2877 ));
2878 assert2::assert!(m.scram_credential("alice", mech).is_none());
2879 }
2880
2881 fn topic_read_for_alice() -> AclEntry {
2882 AclEntry {
2883 resource_type: ResourceType::Topic,
2884 resource_name: "foo".into(),
2885 pattern_type: PatternType::Literal,
2886 principal: "User:alice".into(),
2887 host: "*".into(),
2888 operation: AclOperation::Read,
2889 permission_type: PermissionType::Allow,
2890 }
2891 }
2892
2893 fn topic_prefixed_team() -> AclEntry {
2894 AclEntry {
2895 resource_type: ResourceType::Topic,
2896 resource_name: "team-".into(),
2897 pattern_type: PatternType::Prefixed,
2898 principal: "User:alice".into(),
2899 host: "*".into(),
2900 operation: AclOperation::Read,
2901 permission_type: PermissionType::Allow,
2902 }
2903 }
2904
2905 #[test]
2906 fn apply_v1_access_control_entry_literal_stores_in_literal_map() {
2907 let mut m = img();
2908 m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2909 let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2910 assert2::assert!(
2911 hits.iter()
2912 .map(|entry| entry.resource_name.as_str())
2913 .collect::<Vec<_>>()
2914 == vec!["foo"]
2915 );
2916 }
2917
2918 #[test]
2919 fn apply_v1_access_control_entry_replaces_identical_entry() {
2920 let mut m = img();
2921 let acl = topic_read_for_alice();
2922 m.apply(&MetadataRecord::V1AccessControlEntry(acl.clone()));
2923 m.apply(&MetadataRecord::V1AccessControlEntry(acl));
2924
2925 let hits = m.matching_acls(ResourceType::Topic, "foo").count();
2926 assert2::assert!(hits == 1);
2927 }
2928
2929 #[test]
2930 fn apply_v1_access_control_entry_prefixed_stores_in_prefixed_vec() {
2931 let mut m = img();
2932 m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2933 let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "team-foo").collect();
2934 assert2::assert!(
2935 hits.iter()
2936 .map(|entry| entry.resource_name.as_str())
2937 .collect::<Vec<_>>()
2938 == vec!["team-"]
2939 );
2940 let none = m.matching_acls(ResourceType::Topic, "other").count();
2942 assert2::assert!(none == 0);
2943 }
2944
2945 #[test]
2946 fn matching_acls_combines_literal_and_prefixed() {
2947 let mut m = img();
2948 m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2949 m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2950 let hits_foo = m.matching_acls(ResourceType::Topic, "foo").count();
2951 let hits_team = m.matching_acls(ResourceType::Topic, "team-x").count();
2952 assert2::assert!((hits_foo, hits_team) == (1, 1));
2953 }
2954
2955 fn topic_wildcard_allow() -> AclEntry {
2956 AclEntry {
2957 resource_type: ResourceType::Topic,
2958 resource_name: "*".into(),
2959 pattern_type: PatternType::Literal,
2960 principal: "User:alice".into(),
2961 host: "*".into(),
2962 operation: AclOperation::Read,
2963 permission_type: PermissionType::Allow,
2964 }
2965 }
2966
2967 fn topic_wildcard_deny() -> AclEntry {
2968 AclEntry {
2969 resource_type: ResourceType::Topic,
2970 resource_name: "*".into(),
2971 pattern_type: PatternType::Literal,
2972 principal: "User:alice".into(),
2973 host: "*".into(),
2974 operation: AclOperation::Read,
2975 permission_type: PermissionType::Deny,
2976 }
2977 }
2978
2979 #[test]
2980 fn matching_acls_literal_wildcard_allow_matches_arbitrary_topic() {
2981 let mut m = img();
2982 m.apply(&MetadataRecord::V1AccessControlEntry(topic_wildcard_allow()));
2983 let hits: Vec<_> = m
2985 .matching_acls(ResourceType::Topic, "some-random-topic")
2986 .collect();
2987 assert2::assert!(hits == vec![&topic_wildcard_allow()]);
2988 let star = m.matching_acls(ResourceType::Topic, "*").count();
2990 assert2::assert!(star == 1);
2991 }
2992
2993 #[test]
2994 fn matching_acls_literal_wildcard_deny_matches_arbitrary_topic() {
2995 let mut m = img();
2996 m.apply(&MetadataRecord::V1AccessControlEntry(topic_wildcard_deny()));
2997 let hits: Vec<_> = m
2998 .matching_acls(ResourceType::Topic, "another-random-topic")
2999 .collect();
3000 assert2::assert!(hits == vec![&topic_wildcard_deny()]);
3001 }
3002
3003 #[test]
3004 fn apply_v1_delete_access_control_entry_removes_matching() {
3005 let mut m = img();
3006 m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
3007 m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
3008 let filter = AclEntryFilter {
3009 resource_type: Some(ResourceType::Topic),
3010 pattern_type: Some(PatternType::Literal),
3011 ..AclEntryFilter::default()
3012 };
3013 m.apply(&MetadataRecord::V1DeleteAccessControlEntry(filter));
3014 let hits_foo = m.matching_acls(ResourceType::Topic, "foo").count();
3015 let hits_team = m.matching_acls(ResourceType::Topic, "team-x").count();
3016 assert2::assert!((hits_foo, hits_team) == (0, 1));
3017 }
3018
3019 #[test]
3020 fn apply_v1_delete_access_control_entry_no_match_is_noop() {
3021 let mut m = img();
3022 m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
3023 let filter = AclEntryFilter {
3024 resource_type: Some(ResourceType::Group),
3025 ..AclEntryFilter::default()
3026 };
3027 m.apply(&MetadataRecord::V1DeleteAccessControlEntry(filter));
3028 let hits = m.matching_acls(ResourceType::Topic, "foo").count();
3029 assert2::assert!(hits == 1);
3030 }
3031
3032 #[test]
3033 fn all_acls_returns_every_entry() {
3034 let mut m = img();
3035 m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
3036 m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
3037 assert2::assert!(m.all_acls().count() == 2);
3038 }
3039
3040 #[test]
3041 fn all_partitions_count_matches_map_size() {
3042 let mut m = img();
3043 m.apply(&topic("t", 3));
3044 for p in 0..3 {
3045 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
3046 topic: "t".into(),
3047 partition: p,
3048 leader: NodeId(1),
3049 replicas: vec![NodeId(1)],
3050 isr: vec![NodeId(1)],
3051 leader_epoch: LeaderEpoch(0),
3052 adding_replicas: vec![],
3053 removing_replicas: vec![],
3054 directories: vec![],
3055 partition_epoch: 0,
3056 }));
3057 }
3058 m.apply(&topic("u", 1));
3059 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
3060 topic: "u".into(),
3061 partition: 0,
3062 leader: NodeId(1),
3063 replicas: vec![NodeId(1)],
3064 isr: vec![NodeId(1)],
3065 leader_epoch: LeaderEpoch(0),
3066 adding_replicas: vec![],
3067 removing_replicas: vec![],
3068 directories: vec![],
3069 partition_epoch: 0,
3070 }));
3071 assert2::assert!(m.all_partitions().count() == 4);
3073 assert2::assert!(
3074 m.all_partitions().count()
3075 == m.partitions_of("t").count() + m.partitions_of("u").count()
3076 );
3077 }
3078
3079 #[test]
3085 fn total_partitions_sums_across_topics() {
3086 let mut m = img();
3087 check!(m.total_partitions() == 0);
3089
3090 m.apply(&topic("a", 2));
3092 m.apply(&topic("b", 1));
3093 for (name, idx) in [("a", 0), ("a", 1), ("b", 0)] {
3094 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
3095 topic: name.into(),
3096 partition: idx,
3097 leader: NodeId(1),
3098 replicas: vec![NodeId(1)],
3099 isr: vec![NodeId(1)],
3100 leader_epoch: LeaderEpoch(0),
3101 adding_replicas: vec![],
3102 removing_replicas: vec![],
3103 directories: vec![],
3104 partition_epoch: 0,
3105 }));
3106 }
3107 check!(m.total_partitions() == 3);
3108 check!(m.total_partitions() == m.all_partitions().count());
3110 }
3111
3112 #[test]
3117 fn partitions_of_yields_ascending_partition_index_order() {
3118 let mut m = img();
3119 m.apply(&topic("t", 3));
3120 for p in [2, 0, 1] {
3121 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
3122 topic: "t".into(),
3123 partition: p,
3124 leader: NodeId(1),
3125 replicas: vec![NodeId(1)],
3126 isr: vec![NodeId(1)],
3127 leader_epoch: LeaderEpoch(0),
3128 adding_replicas: vec![],
3129 removing_replicas: vec![],
3130 directories: vec![],
3131 partition_epoch: 0,
3132 }));
3133 }
3134 let idxs: Vec<i32> = m.partitions_of("t").map(|p| p.partition).collect();
3135 assert2::assert!(idxs == vec![0, 1, 2]);
3136 }
3137
3138 #[test]
3142 fn delete_topic_leaves_other_topics_partitions_intact() {
3143 let mut m = img();
3144 m.apply(&topic("doomed", 1));
3145 m.apply(&topic("survivor", 1));
3146 for name in ["doomed", "survivor"] {
3147 m.apply(&MetadataRecord::V1Partition(PartitionRecord {
3148 topic: name.into(),
3149 partition: 0,
3150 leader: NodeId(1),
3151 replicas: vec![NodeId(1)],
3152 isr: vec![NodeId(1)],
3153 leader_epoch: LeaderEpoch(0),
3154 adding_replicas: vec![],
3155 removing_replicas: vec![],
3156 directories: vec![],
3157 partition_epoch: 0,
3158 }));
3159 }
3160 m.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
3161 name: "doomed".into(),
3162 }));
3163 check!(
3164 (
3165 m.partitions_of("doomed").count(),
3166 m.topic_partition_count("doomed"),
3167 m.partitions_of("survivor").count(),
3168 m.partition("survivor", 0).is_some(),
3169 m.all_partitions().count(),
3170 ) == (0, 0, 1, true, 1)
3171 );
3172 }
3173
3174 #[test]
3175 fn reassignments_in_flight_excludes_idle_partitions() {
3176 let mut img = MetadataImage::new(uuid::Uuid::nil());
3177 img.apply(&MetadataRecord::V1Topic(TopicRecord {
3178 name: "foo".into(),
3179 topic_id: uuid::Uuid::nil(),
3180 partitions: 1,
3181 replication_factor: 3,
3182 }));
3183 img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3184 topic: "foo".into(),
3185 partition: 0,
3186 leader: NodeId(1),
3187 replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3188 isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3189 leader_epoch: LeaderEpoch(0),
3190 adding_replicas: vec![],
3191 removing_replicas: vec![],
3192 directories: vec![],
3193 partition_epoch: 0,
3194 }));
3195 assert2::assert!(img.reassignments_in_flight().count() == 0);
3196 }
3197
3198 #[test]
3199 fn reassignments_in_flight_returns_partitions_with_adding() {
3200 let mut img = MetadataImage::new(uuid::Uuid::nil());
3201 img.apply(&MetadataRecord::V1Topic(TopicRecord {
3202 name: "foo".into(),
3203 topic_id: uuid::Uuid::nil(),
3204 partitions: 1,
3205 replication_factor: 3,
3206 }));
3207 img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3208 topic: "foo".into(),
3209 partition: 0,
3210 leader: NodeId(1),
3211 replicas: vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
3212 isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3213 leader_epoch: LeaderEpoch(0),
3214 adding_replicas: vec![NodeId(4)],
3215 removing_replicas: vec![],
3216 directories: vec![],
3217 partition_epoch: 0,
3218 }));
3219 let rows: Vec<_> = img.reassignments_in_flight().collect();
3220 assert2::assert!(
3221 rows.iter()
3222 .map(|row| row.adding_replicas.clone())
3223 .collect::<Vec<_>>()
3224 == vec![vec![NodeId(4)]]
3225 );
3226 }
3227
3228 #[test]
3229 fn reassignments_in_flight_returns_partitions_with_removing() {
3230 let mut img = MetadataImage::new(uuid::Uuid::nil());
3231 img.apply(&MetadataRecord::V1Topic(TopicRecord {
3232 name: "foo".into(),
3233 topic_id: uuid::Uuid::nil(),
3234 partitions: 1,
3235 replication_factor: 3,
3236 }));
3237 img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3238 topic: "foo".into(),
3239 partition: 0,
3240 leader: NodeId(1),
3241 replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3242 isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3243 leader_epoch: LeaderEpoch(0),
3244 adding_replicas: vec![],
3245 removing_replicas: vec![NodeId(3)],
3246 directories: vec![],
3247 partition_epoch: 0,
3248 }));
3249 let rows: Vec<_> = img.reassignments_in_flight().collect();
3250 assert2::assert!(
3251 rows.iter()
3252 .map(|row| row.removing_replicas.clone())
3253 .collect::<Vec<_>>()
3254 == vec![vec![NodeId(3)]]
3255 );
3256 }
3257
3258 #[test]
3259 fn reassignments_in_flight_covers_multiple_topics() {
3260 let mut img = MetadataImage::new(uuid::Uuid::nil());
3261 for name in ["foo", "bar"] {
3262 img.apply(&MetadataRecord::V1Topic(TopicRecord {
3263 name: name.into(),
3264 topic_id: uuid::Uuid::nil(),
3265 partitions: 1,
3266 replication_factor: 3,
3267 }));
3268 img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3269 topic: name.into(),
3270 partition: 0,
3271 leader: NodeId(1),
3272 replicas: vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
3273 isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3274 leader_epoch: LeaderEpoch(0),
3275 adding_replicas: vec![NodeId(4)],
3276 removing_replicas: vec![],
3277 directories: vec![],
3278 partition_epoch: 0,
3279 }));
3280 }
3281 assert2::assert!(img.reassignments_in_flight().count() == 2);
3282 }
3283
3284 #[test]
3285 fn broker_config_set_inserts_into_image() {
3286 let mut img = MetadataImage::new(uuid::Uuid::nil());
3287 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3288 node_id: NodeId(1),
3289 config_name: "leader.replication.throttled.rate".into(),
3290 config_value: Some("2048".into()),
3291 }));
3292 let bc = img.broker_config(NodeId(1)).expect("broker config");
3293 assert2::assert!(bc.get("leader.replication.throttled.rate") == Some(&"2048".to_owned()));
3294 }
3295
3296 #[test]
3297 fn broker_config_delete_removes_from_image() {
3298 let mut img = MetadataImage::new(uuid::Uuid::nil());
3299 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3300 node_id: NodeId(1),
3301 config_name: "leader.replication.throttled.rate".into(),
3302 config_value: Some("2048".into()),
3303 }));
3304 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3305 node_id: NodeId(1),
3306 config_name: "leader.replication.throttled.rate".into(),
3307 config_value: None,
3308 }));
3309 let bc = img
3310 .broker_config(NodeId(1))
3311 .expect("broker_configs entry retained");
3312 assert2::assert!(bc.get("leader.replication.throttled.rate").is_none());
3313 }
3314
3315 #[test]
3316 fn broker_throttle_rate_parses_positive_value() {
3317 let mut img = MetadataImage::new(uuid::Uuid::nil());
3318 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3319 node_id: NodeId(1),
3320 config_name: "leader.replication.throttled.rate".into(),
3321 config_value: Some("2048".into()),
3322 }));
3323 assert2::assert!(
3324 img.broker_throttle_rate(NodeId(1), ThrottleKind::Leader) == Some(bytes_per_sec(2048))
3325 );
3326
3327 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3328 node_id: NodeId(1),
3329 config_name: "replica.alter.log.dirs.io.max.bytes.per.second".into(),
3330 config_value: Some("4096".into()),
3331 }));
3332 assert2::assert!(
3333 img.broker_throttle_rate(NodeId(1), ThrottleKind::AlterLogDirs)
3334 == Some(bytes_per_sec(4096))
3335 );
3336 }
3337
3338 #[test]
3344 fn broker_throttle_rate_round_trips_large_quotas_exactly() {
3345 for raw in ["0", "1", "1073741824", "9007199254740992"] {
3346 let mut img = MetadataImage::new(uuid::Uuid::nil());
3347 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3348 node_id: NodeId(7),
3349 config_name: "follower.replication.throttled.rate".into(),
3350 config_value: Some(raw.into()),
3351 }));
3352 let rate = img
3353 .broker_throttle_rate(NodeId(7), ThrottleKind::Follower)
3354 .expect("configured rate");
3355 assert2::check!(rate.bytes_per_sec_i64().to_string() == raw);
3356 }
3357 }
3358
3359 #[test]
3360 fn broker_throttle_rate_returns_none_for_negative_one() {
3361 let mut img = MetadataImage::new(uuid::Uuid::nil());
3362 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3363 node_id: NodeId(1),
3364 config_name: "leader.replication.throttled.rate".into(),
3365 config_value: Some("-1".into()),
3366 }));
3367 assert2::assert!(
3368 img.broker_throttle_rate(NodeId(1), ThrottleKind::Leader)
3369 .is_none()
3370 );
3371 }
3372
3373 #[test]
3374 fn broker_throttle_rate_uses_cluster_default() {
3375 let mut img = MetadataImage::new(uuid::Uuid::nil());
3376 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3377 node_id: DEFAULT_BROKER_CONFIG_NODE_ID,
3378 config_name: "leader.replication.throttled.rate".into(),
3379 config_value: Some("2048".into()),
3380 }));
3381
3382 assert2::assert!(
3383 img.default_broker_config()
3384 .and_then(|configs| configs.get("leader.replication.throttled.rate"))
3385 .map(String::as_str)
3386 == Some("2048")
3387 );
3388 assert2::assert!(
3389 img.broker_throttle_rate(NodeId(7), ThrottleKind::Leader) == Some(bytes_per_sec(2048))
3390 );
3391 }
3392
3393 #[test]
3394 fn per_broker_disabled_rate_overrides_cluster_default() {
3395 let mut img = MetadataImage::new(uuid::Uuid::nil());
3396 for (node_id, value) in [(DEFAULT_BROKER_CONFIG_NODE_ID, "2048"), (NodeId(7), "-1")] {
3397 img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3398 node_id,
3399 config_name: "leader.replication.throttled.rate".into(),
3400 config_value: Some(value.into()),
3401 }));
3402 }
3403
3404 assert2::assert!(
3405 img.broker_throttle_rate(NodeId(7), ThrottleKind::Leader)
3406 .is_none()
3407 );
3408 }
3409
3410 #[test]
3411 fn client_quota_apply_inserts_canonicalized() {
3412 let mut img = MetadataImage::new(uuid::Uuid::nil());
3413 img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3415 entity: vec![
3416 QuotaEntity {
3417 entity_type: "user".into(),
3418 entity_name: Some("alice".into()),
3419 },
3420 QuotaEntity {
3421 entity_type: "client-id".into(),
3422 entity_name: Some("app1".into()),
3423 },
3424 ],
3425 config_key: "producer_byte_rate".into(),
3426 config_value: Some(1024.0),
3427 }));
3428 let key: EntityKey = vec![
3429 ("client-id".into(), Some("app1".into())),
3430 ("user".into(), Some("alice".into())),
3431 ];
3432 let configs = img
3433 .client_quotas()
3434 .get(&key)
3435 .expect("entry under canonical key");
3436 assert2::assert!(configs.get("producer_byte_rate") == Some(&1024.0));
3437 }
3438
3439 #[test]
3440 fn client_quota_apply_delete_removes_key() {
3441 let mut img = MetadataImage::new(uuid::Uuid::nil());
3442 img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3443 entity: vec![QuotaEntity {
3444 entity_type: "user".into(),
3445 entity_name: Some("alice".into()),
3446 }],
3447 config_key: "producer_byte_rate".into(),
3448 config_value: Some(1024.0),
3449 }));
3450 img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3451 entity: vec![QuotaEntity {
3452 entity_type: "user".into(),
3453 entity_name: Some("alice".into()),
3454 }],
3455 config_key: "producer_byte_rate".into(),
3456 config_value: None,
3457 }));
3458 let key: EntityKey = vec![("user".into(), Some("alice".into()))];
3459 let configs = img.client_quotas().get(&key).expect("entry retained");
3460 assert2::assert!(configs.get("producer_byte_rate").is_none());
3461 }
3462
3463 #[test]
3464 fn client_quota_default_entity_uses_none_name() {
3465 let mut img = MetadataImage::new(uuid::Uuid::nil());
3466 img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3467 entity: vec![QuotaEntity {
3468 entity_type: "user".into(),
3469 entity_name: None,
3470 }],
3471 config_key: "producer_byte_rate".into(),
3472 config_value: Some(512.0),
3473 }));
3474 let key: EntityKey = vec![("user".into(), None)];
3475 assert2::assert!(img.client_quotas().contains_key(&key));
3476 }
3477
3478 #[test]
3479 fn canonicalize_sorts_alphabetically_by_entity_type() {
3480 let input = vec![
3481 ("user".to_owned(), Some("alice".to_owned())),
3482 ("client-id".to_owned(), Some("app1".to_owned())),
3483 ];
3484 let canon = canonicalize_entity(input);
3485 assert2::assert!(
3486 canon
3487 .iter()
3488 .map(|(kind, _)| kind.as_str())
3489 .collect::<Vec<_>>()
3490 == vec!["client-id", "user"]
3491 );
3492 }
3493
3494 #[test]
3495 fn scram_credentials_users_returns_distinct_users() {
3496 let mut img = MetadataImage::new(uuid::Uuid::nil());
3497 img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3498 user: "alice".into(),
3499 mechanism: SaslMechanism::ScramSha512,
3500 salt: vec![1, 2, 3],
3501 stored_key: vec![4, 5, 6],
3502 server_key: vec![7, 8, 9],
3503 iterations: 4096,
3504 }));
3505 img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3506 user: "bob".into(),
3507 mechanism: SaslMechanism::ScramSha512,
3508 salt: vec![1, 2, 3],
3509 stored_key: vec![4, 5, 6],
3510 server_key: vec![7, 8, 9],
3511 iterations: 4096,
3512 }));
3513 let mut users = img.scram_credentials_users();
3514 users.sort();
3515 assert2::assert!(users == vec!["alice".to_owned(), "bob".to_owned()]);
3516 }
3517
3518 fn principal(pt: &str, name: &str) -> KafkaPrincipal {
3519 KafkaPrincipal {
3520 principal_type: pt.into(),
3521 name: name.into(),
3522 }
3523 }
3524
3525 fn dt_record(
3526 token_id: &str,
3527 owner: KafkaPrincipal,
3528 expiry_timestamp_ms: i64,
3529 renewers: Vec<KafkaPrincipal>,
3530 ) -> MetadataRecord {
3531 MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3532 token_id: token_id.into(),
3533 owner: owner.clone(),
3534 requester: owner,
3535 issue_timestamp_ms: 1_000,
3536 expiry_timestamp_ms,
3537 max_timestamp_ms: 10_000,
3538 renewers,
3539 })
3540 }
3541
3542 #[test]
3543 fn apply_delegation_token_insert_and_replace() {
3544 let mut img = MetadataImage::new(uuid::Uuid::nil());
3545 let alice = principal("User", "alice");
3546
3547 img.apply(&dt_record("tok-1", alice.clone(), 5_000, vec![]));
3548 let got = img.delegation_token_by_id("tok-1").expect("token present");
3549 assert2::assert!(got.expiry_timestamp_ms == 5_000);
3550 assert2::assert!(&got.owner == &alice);
3551
3552 img.apply(&dt_record("tok-1", alice, 7_500, vec![]));
3554 let got = img.delegation_token_by_id("tok-1").expect("token present");
3555 assert2::assert!(got.expiry_timestamp_ms == 7_500);
3556 assert2::assert!(img.all_delegation_tokens().count() == 1);
3557 }
3558
3559 #[test]
3560 fn apply_delete_delegation_token_removes_from_image() {
3561 let mut img = MetadataImage::new(uuid::Uuid::nil());
3562 let alice = principal("User", "alice");
3563
3564 img.apply(&dt_record("tok-1", alice, 5_000, vec![]));
3565 assert2::assert!(img.delegation_token_by_id("tok-1").is_some());
3566
3567 img.apply(&MetadataRecord::V1DeleteDelegationToken(
3568 DeleteDelegationTokenRecord {
3569 token_id: "tok-1".into(),
3570 },
3571 ));
3572 assert2::assert!(img.delegation_token_by_id("tok-1").is_none());
3573 assert2::assert!(img.all_delegation_tokens().count() == 0);
3574 }
3575
3576 #[test]
3577 fn delegation_token_by_hmac_recomputes_each_tokens_hmac() {
3578 let mut img = MetadataImage::new(uuid::Uuid::nil());
3579 img.apply(&dt_record(
3580 "tok-a",
3581 principal("User", "alice"),
3582 5_000,
3583 vec![],
3584 ));
3585 img.apply(&dt_record("tok-b", principal("User", "bob"), 5_000, vec![]));
3586 let hmac_b = krabka_security::compute_token_hmac(b"key", "tok-b");
3587
3588 let cases: [(&[u8], &[u8], Option<&str>); 3] = [
3590 (b"key", &hmac_b, Some("tok-b")),
3591 (b"other", &hmac_b, None),
3593 (b"key", &[0xCC; 64], None),
3594 ];
3595 for (secret_key, hmac, expected) in cases {
3596 check!(
3597 img.delegation_token_by_hmac(secret_key, hmac)
3598 .map(|t| t.token_id.as_str())
3599 == expected
3600 );
3601 }
3602 }
3603
3604 #[test]
3605 fn delegation_tokens_by_owner_filters_correctly() {
3606 let mut img = MetadataImage::new(uuid::Uuid::nil());
3607 let alice = principal("User", "alice");
3608 let bob = principal("User", "bob");
3609
3610 img.apply(&dt_record("a-1", alice.clone(), 5_000, vec![]));
3611 img.apply(&dt_record("a-2", alice.clone(), 6_000, vec![bob.clone()]));
3612 img.apply(&dt_record("b-1", bob.clone(), 7_000, vec![]));
3613
3614 let alice_tokens = img.delegation_tokens_by_owner(&alice);
3615 assert2::assert!(alice_tokens.len() == 2);
3616 assert2::assert!(alice_tokens.iter().all(|token| token.owner == alice));
3617
3618 let bob_tokens = img.delegation_tokens_by_owner(&bob);
3619 assert2::assert!(
3620 bob_tokens
3621 .iter()
3622 .map(|token| token.token_id.as_str())
3623 .collect::<Vec<_>>()
3624 == vec!["b-1"]
3625 );
3626
3627 let bob_visible = img.delegation_tokens_visible_to(&bob);
3629 assert2::assert!(bob_visible.len() == 2);
3630 let mut ids: Vec<&str> = bob_visible.iter().map(|t| t.token_id.as_str()).collect();
3631 ids.sort_unstable();
3632 assert2::assert!(ids == vec!["a-2", "b-1"]);
3633 }
3634
3635 #[test]
3638 fn delegation_tokens_are_visible_to_their_requester() {
3639 let mut img = MetadataImage::new(uuid::Uuid::nil());
3640 let alice = principal("User", "alice");
3641 let admin = principal("User", "admin");
3642 img.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3643 requester: admin.clone(),
3644 ..match dt_record("a-1", alice.clone(), 5_000, vec![]) {
3645 MetadataRecord::V1DelegationToken(record) => record,
3646 _ => unreachable!("dt_record builds a token record"),
3647 }
3648 }));
3649 img.apply(&dt_record("a-2", alice.clone(), 5_000, vec![]));
3650
3651 let cases = [
3653 (&admin, vec!["a-1"]),
3654 (&alice, vec!["a-1", "a-2"]),
3655 (&principal("User", "carol"), vec![]),
3656 ];
3657 for (who, expected) in cases {
3658 let mut ids: Vec<&str> = img
3659 .delegation_tokens_visible_to(who)
3660 .iter()
3661 .map(|t| t.token_id.as_str())
3662 .collect();
3663 ids.sort_unstable();
3664 check!(ids == expected, "{who}");
3665 }
3666 }
3667
3668 #[test]
3669 fn applies_voters_and_version() {
3670 let mut image = MetadataImage::new(Uuid::nil());
3671 image.apply(&MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
3672 kraft_version: 1,
3673 }));
3674 image.apply(&MetadataRecord::V1Voters(VotersRecord {
3675 voters: VoterSet::from_voters([Voter {
3676 id: NodeId(1),
3677 directory_id: uuid::Uuid::nil(),
3678 endpoints: vec![],
3679 kraft_version: KRaftVersionRange::default(),
3680 }]),
3681 }));
3682 assert2::assert!(image.kraft_version() == 1);
3683 assert2::assert!(image.voters().contains(NodeId(1)));
3684 }
3685
3686 #[test]
3687 fn scram_credentials_for_user_returns_pairs() {
3688 let mut img = MetadataImage::new(uuid::Uuid::nil());
3689 img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3690 user: "alice".into(),
3691 mechanism: SaslMechanism::ScramSha512,
3692 salt: vec![1, 2, 3],
3693 stored_key: vec![4, 5, 6],
3694 server_key: vec![7, 8, 9],
3695 iterations: 8192,
3696 }));
3697 let pairs = img.scram_credentials_for_user("alice");
3698 assert2::assert!(pairs == vec![(SaslMechanism::ScramSha512, 8192)]);
3699 assert2::assert!(img.scram_credentials_for_user("ghost").is_empty());
3700 }
3701
3702 #[test]
3703 fn finalized_metadata_version_reads_feature_map() {
3704 use crate::records::FeatureLevelRecord;
3705 let mut m = img();
3706 assert2::assert!(m.finalized_metadata_version() == None);
3707 m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
3708 name: "metadata.version".into(),
3709 level: 19,
3710 }));
3711 assert2::assert!(m.finalized_metadata_version() == Some(19));
3712 }
3713
3714 #[test]
3715 fn min_required_metadata_version_baseline_is_min() {
3716 use crate::metadata_version::METADATA_VERSION_MIN;
3717 let m = img();
3718 assert2::assert!(m.min_required_metadata_version() == METADATA_VERSION_MIN);
3719 }
3720
3721 #[test]
3722 fn client_metrics_config_apply_and_clear() {
3723 use crate::records::ClientMetricsConfigRecord;
3724 let mut img = MetadataImage::new(uuid::Uuid::nil());
3725 let mut cfgs = BTreeMap::new();
3726 cfgs.insert("interval.ms".to_owned(), "60000".to_owned());
3727 img.apply(&MetadataRecord::V1ClientMetricsConfig(
3728 ClientMetricsConfigRecord {
3729 name: "sub-a".into(),
3730 configs: cfgs,
3731 },
3732 ));
3733 assert2::assert!(
3734 img.client_metrics_config("sub-a")
3735 .and_then(|m| m.get("interval.ms"))
3736 .map(String::as_str)
3737 == Some("60000")
3738 );
3739 assert2::assert!(img.client_metrics_subscriptions().count() == 1);
3740 img.apply(&MetadataRecord::V1ClientMetricsConfig(
3741 ClientMetricsConfigRecord {
3742 name: "sub-a".into(),
3743 configs: BTreeMap::new(),
3744 },
3745 ));
3746 assert2::assert!(img.client_metrics_config("sub-a").is_none());
3747 assert2::assert!(img.client_metrics_subscriptions().count() == 0);
3748 }
3749
3750 #[test]
3751 fn group_config_apply_snapshot_and_clear() {
3752 let mut image = MetadataImage::new(Uuid::nil());
3753 let record = MetadataRecord::V1GroupConfig(GroupConfigRecord {
3754 group_id: "streams-app".into(),
3755 configs: BTreeMap::from([("streams.num.standby.replicas".into(), "1".into())]),
3756 });
3757 image.apply(&record);
3758 assert2::assert!(
3759 image
3760 .group_config("streams-app")
3761 .and_then(|configs| configs.get("streams.num.standby.replicas"))
3762 .map(String::as_str)
3763 == Some("1")
3764 );
3765 assert2::assert!(image.to_records().iter().any(|item| item == &record));
3766
3767 image.apply(&MetadataRecord::V1GroupConfig(GroupConfigRecord {
3768 group_id: "streams-app".into(),
3769 configs: BTreeMap::new(),
3770 }));
3771 assert2::assert!(image.group_config("streams-app").is_none());
3772 }
3773
3774 #[test]
3775 fn producer_id_boundary_is_monotonic_and_survives_snapshot() {
3776 let mut image = MetadataImage::new(Uuid::nil());
3777 image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3778 broker_id: NodeId(1),
3779 broker_epoch: 4,
3780 next_producer_id: 1_000,
3781 }));
3782 image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3783 broker_id: NodeId(2),
3784 broker_epoch: 8,
3785 next_producer_id: 2_000,
3786 }));
3787 image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3788 broker_id: NodeId(1),
3789 broker_epoch: 4,
3790 next_producer_id: 1_000,
3791 }));
3792 assert2::assert!(image.next_producer_id() == 2_000);
3793
3794 let restored = MetadataImage::from_records(Uuid::nil(), &image.to_records());
3795 assert2::assert!(restored.next_producer_id() == 2_000);
3796 }
3797
3798 #[test]
3801 fn cidr_acl_hosts_follow_the_kip_1276_gate() {
3802 use crate::{
3803 AclEntryFilter,
3804 acl::{AclOperation, PermissionType},
3805 metadata_version::{CIDR_ACL_MIN_LEVEL, METADATA_VERSION_MIN},
3806 };
3807
3808 let acl = |host: &str| AclEntry {
3809 resource_type: ResourceType::Topic,
3810 resource_name: "orders".into(),
3811 pattern_type: PatternType::Literal,
3812 principal: "User:alice".into(),
3813 host: host.into(),
3814 operation: AclOperation::Read,
3815 permission_type: PermissionType::Allow,
3816 };
3817 let delete = |host: &str| {
3818 let entry = acl(host);
3819 MetadataRecord::V1DeleteAccessControlEntry(AclEntryFilter {
3820 resource_type: Some(entry.resource_type),
3821 resource_name: Some(entry.resource_name),
3822 pattern_type: Some(entry.pattern_type),
3823 principal: Some(entry.principal),
3824 host: Some(entry.host),
3825 operation: Some(entry.operation),
3826 permission_type: Some(entry.permission_type),
3827 })
3828 };
3829 for (case, hosts, floor, cidr_hosts) in [
3830 ("no ACLs", vec![], METADATA_VERSION_MIN, vec![]),
3831 (
3832 "wildcard and address",
3833 vec!["*", "10.0.0.1"],
3834 METADATA_VERSION_MIN,
3835 vec![],
3836 ),
3837 (
3838 "IPv4 CIDR",
3839 vec!["*", "10.0.0.0/8"],
3840 CIDR_ACL_MIN_LEVEL,
3841 vec!["10.0.0.0/8"],
3842 ),
3843 (
3844 "IPv6 CIDR",
3845 vec!["2001:db8::/32"],
3846 CIDR_ACL_MIN_LEVEL,
3847 vec!["2001:db8::/32"],
3848 ),
3849 ] {
3850 let mut image = img();
3851 for host in &hosts {
3852 image.apply(&MetadataRecord::V1AccessControlEntry(acl(host)));
3853 }
3854 let want_cleanup: Vec<_> = cidr_hosts.into_iter().map(delete).collect();
3855 assert2::check!(
3856 (
3857 image.min_required_metadata_version(),
3858 image.metadata_version_downgrade_records(CIDR_ACL_MIN_LEVEL),
3859 image.metadata_version_downgrade_records(CIDR_ACL_MIN_LEVEL - 1),
3860 ) == (floor, vec![], want_cleanup.clone()),
3861 "case {case}"
3862 );
3863 for record in &want_cleanup {
3864 image.apply(record);
3865 }
3866 assert2::check!(
3867 image.min_required_metadata_version() == METADATA_VERSION_MIN,
3868 "case {case} after cleanup"
3869 );
3870 }
3871 }
3872
3873 #[test]
3874 fn min_required_metadata_version_rises_with_scram_and_tokens() {
3875 use krabka_security::{KafkaPrincipal, SaslMechanism};
3876
3877 use crate::metadata_version::{DELEGATION_TOKEN_MIN_LEVEL, SCRAM_MIN_LEVEL};
3878 let mut m = img();
3879 m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3880 user: "alice".into(),
3881 mechanism: SaslMechanism::ScramSha512,
3882 salt: vec![1; 16],
3883 stored_key: vec![2; 64],
3884 server_key: vec![3; 64],
3885 iterations: 4096,
3886 }));
3887 assert2::assert!(m.min_required_metadata_version() == SCRAM_MIN_LEVEL);
3888 m.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3889 token_id: "t1".into(),
3890 owner: KafkaPrincipal {
3891 principal_type: "User".into(),
3892 name: "alice".into(),
3893 },
3894 requester: KafkaPrincipal {
3895 principal_type: "User".into(),
3896 name: "alice".into(),
3897 },
3898 issue_timestamp_ms: 1,
3899 expiry_timestamp_ms: 5,
3900 max_timestamp_ms: 10,
3901 renewers: vec![],
3902 }));
3903 assert2::assert!(m.min_required_metadata_version() == DELEGATION_TOKEN_MIN_LEVEL);
3904 }
3905
3906 #[test]
3907 fn downgrade_projection_removes_scram_and_tokens_below_their_record_gates() {
3908 use krabka_security::{KafkaPrincipal, SaslMechanism};
3909
3910 use crate::{
3911 metadata_version::{METADATA_VERSION_MIN, SCRAM_MIN_LEVEL},
3912 records::{
3913 DelegationTokenRecord, DeleteDelegationTokenRecord, DeleteScramCredentialRecord,
3914 ScramCredentialRecord,
3915 },
3916 };
3917
3918 let mut image = img();
3919 image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3920 user: "alice".into(),
3921 mechanism: SaslMechanism::ScramSha512,
3922 salt: vec![1; 16],
3923 stored_key: vec![2; 64],
3924 server_key: vec![3; 64],
3925 iterations: 4096,
3926 }));
3927 image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3928 token_id: "token-1".into(),
3929 owner: KafkaPrincipal {
3930 principal_type: "User".into(),
3931 name: "alice".into(),
3932 },
3933 requester: KafkaPrincipal {
3934 principal_type: "User".into(),
3935 name: "alice".into(),
3936 },
3937 issue_timestamp_ms: 1,
3938 expiry_timestamp_ms: 5,
3939 max_timestamp_ms: 10,
3940 renewers: vec![],
3941 }));
3942
3943 assert2::assert!(
3948 image
3949 .metadata_version_downgrade_records(SCRAM_MIN_LEVEL)
3950 .iter()
3951 .all(|r| !matches!(r, MetadataRecord::V1DeleteScramCredential(_)))
3952 );
3953 assert2::assert!(
3954 image
3955 .metadata_version_downgrade_records(metadata_version::DELEGATION_TOKEN_MIN_LEVEL)
3956 .iter()
3957 .all(|r| !matches!(r, MetadataRecord::V1DeleteDelegationToken(_)))
3958 );
3959
3960 let cleanup = image.metadata_version_downgrade_records(SCRAM_MIN_LEVEL - 1);
3961 assert2::assert!(
3962 cleanup
3963 == vec![
3964 MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
3965 user: "alice".into(),
3966 mechanism: SaslMechanism::ScramSha512,
3967 }),
3968 MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
3969 token_id: "token-1".into(),
3970 }),
3971 ]
3972 );
3973
3974 for record in &cleanup {
3975 image.apply(record);
3976 }
3977 assert2::assert!(image.scram_credentials_for_user("alice").is_empty());
3978 assert2::assert!(image.all_delegation_tokens().next().is_none());
3979 assert2::assert!(image.min_required_metadata_version() == METADATA_VERSION_MIN);
3980 }
3981
3982 #[test]
3983 fn downgrade_projection_clears_directory_fields_below_kip_858() {
3984 use crate::metadata_version::DIRECTORY_ASSIGNMENT_MIN_LEVEL;
3985
3986 let directory = Uuid::from_u128(0xD1);
3987 let mut image = img();
3988 let broker = BrokerRegistrationRecord {
3989 node_id: NodeId(1),
3990 broker_epoch: 7,
3991 incarnation_id: Uuid::from_u128(1),
3992 host: "broker-1".into(),
3993 port: 9092,
3994 rack: None,
3995 endpoints: vec![],
3996 log_dirs: vec![directory],
3997 fenced: false,
3998 in_controlled_shutdown: false,
3999 cordoned_log_dirs: None,
4000 features: BTreeMap::new(),
4001 };
4002 image.apply(&MetadataRecord::V1BrokerRegistration(broker.clone()));
4003 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4004 topic: "orders".into(),
4005 partition: 0,
4006 leader: NodeId(1),
4007 replicas: vec![NodeId(1), NodeId(2)],
4008 isr: vec![NodeId(1), NodeId(2)],
4009 directories: vec![directory, MIGRATING],
4010 ..Default::default()
4011 }));
4012
4013 assert2::assert!(
4014 image
4015 .metadata_version_downgrade_records(DIRECTORY_ASSIGNMENT_MIN_LEVEL)
4016 .is_empty()
4017 );
4018 let cleanup = image.metadata_version_downgrade_records(DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1);
4019 let mut projected_broker = broker;
4020 projected_broker.log_dirs.clear();
4021 assert2::assert!(
4022 cleanup
4023 == vec![
4024 MetadataRecord::V1BrokerRegistration(projected_broker),
4025 MetadataRecord::V1PartitionDirAssignment(PartitionDirAssignmentRecord {
4026 topic: "orders".into(),
4027 partition: 0,
4028 replica: NodeId(1),
4029 directory: MIGRATING,
4030 },),
4031 ]
4032 );
4033
4034 for record in &cleanup {
4035 image.apply(record);
4036 }
4037 assert2::assert!(
4038 image.partition("orders", 0).expect("partition").directories == vec![MIGRATING; 2]
4039 );
4040 assert2::assert!(image.broker(NodeId(1)).expect("broker").log_dirs.is_empty());
4041 }
4042
4043 #[test]
4047 fn downgrade_projection_clears_cordoned_log_dirs_below_kip_1066() {
4048 use crate::metadata_version::{
4049 CORDONED_LOG_DIRS_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL,
4050 };
4051
4052 let directory = Uuid::from_u128(0xD1);
4053 let broker = BrokerRegistrationRecord {
4054 node_id: NodeId(1),
4055 broker_epoch: 7,
4056 incarnation_id: Uuid::from_u128(1),
4057 host: "broker-1".into(),
4058 port: 9092,
4059 rack: None,
4060 endpoints: vec![],
4061 log_dirs: vec![directory],
4062 fenced: false,
4063 in_controlled_shutdown: false,
4064 cordoned_log_dirs: Some(vec![directory]),
4065 features: BTreeMap::new(),
4066 };
4067 let mut image = img();
4068 image.apply(&MetadataRecord::V1BrokerRegistration(broker.clone()));
4069
4070 let uncordoned = BrokerRegistrationRecord {
4071 cordoned_log_dirs: None,
4072 ..broker
4073 };
4074 let without_dirs = BrokerRegistrationRecord {
4075 log_dirs: vec![],
4076 ..uncordoned.clone()
4077 };
4078 for (target, want) in [
4079 (CORDONED_LOG_DIRS_MIN_LEVEL, vec![]),
4080 (
4081 CORDONED_LOG_DIRS_MIN_LEVEL - 1,
4082 vec![MetadataRecord::V1BrokerRegistration(uncordoned)],
4083 ),
4084 (
4085 DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1,
4086 vec![MetadataRecord::V1BrokerRegistration(without_dirs)],
4087 ),
4088 ] {
4089 assert2::check!(
4090 image.metadata_version_downgrade_records(target) == want,
4091 "target {target}"
4092 );
4093 }
4094 }
4095
4096 #[test]
4097 fn topic_by_id_resolves_and_drops_on_delete() {
4098 use crate::records::{MetadataRecord, TopicRecord};
4099 let mut img = MetadataImage::new(Uuid::nil());
4100 let id = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
4101 img.apply(&MetadataRecord::V1Topic(TopicRecord {
4102 name: "orders".into(),
4103 topic_id: id,
4104 partitions: 1,
4105 replication_factor: 1,
4106 }));
4107
4108 assert2::assert!(img.topic_by_id(&id).map(|t| t.name.as_str()) == Some("orders"));
4110 assert2::assert!(img.topic_name_by_id(&id) == Some("orders"));
4111
4112 img.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
4114 name: "orders".into(),
4115 }));
4116 assert2::assert!(img.topic_by_id(&id).is_none());
4117 assert2::assert!(img.topic_name_by_id(&id).is_none());
4118 }
4119
4120 #[test]
4121 fn broker_epoch_reads_back_registered_epoch() {
4122 let mut image = MetadataImage::new(Uuid::nil());
4123 image.apply(&MetadataRecord::V1BrokerRegistration(
4124 BrokerRegistrationRecord {
4125 node_id: NodeId(5),
4126 broker_epoch: 99,
4127 incarnation_id: Uuid::nil(),
4128 host: "h".into(),
4129 port: 9092,
4130 rack: None,
4131 log_dirs: vec![],
4132 fenced: false,
4133 in_controlled_shutdown: false,
4134 cordoned_log_dirs: None,
4135 endpoints: vec![],
4136 features: BTreeMap::new(),
4137 },
4138 ));
4139 assert2::assert!(image.broker_epoch(NodeId(5)) == Some(99));
4140 assert2::assert!(image.broker_epoch(NodeId(404)) == None);
4141 }
4142
4143 #[test]
4146 fn fresh_image_accessors() {
4147 let id = Uuid::from_u128(0xABCD);
4148 let image = MetadataImage::new(id);
4149 assert2::assert!(image.cluster_id() == id);
4150 assert2::assert!(image.kraft_version() == 0);
4151 }
4152
4153 #[test]
4154 fn broker_lookup_returns_registered_record() {
4155 let mut image = MetadataImage::new(Uuid::nil());
4156 image.apply(&MetadataRecord::V1BrokerRegistration(
4157 BrokerRegistrationRecord {
4158 node_id: NodeId(7),
4159 broker_epoch: 1,
4160 incarnation_id: Uuid::nil(),
4161 host: "h".into(),
4162 port: 9092,
4163 rack: None,
4164 log_dirs: vec![],
4165 fenced: false,
4166 in_controlled_shutdown: false,
4167 cordoned_log_dirs: None,
4168 endpoints: vec![],
4169 features: BTreeMap::new(),
4170 },
4171 ));
4172 let b = image
4173 .broker(NodeId(7))
4174 .expect("registered broker resolvable");
4175 assert2::assert!((b.node_id, image.broker(NodeId(404)).is_none()) == (NodeId(7), true));
4176 }
4177
4178 #[test]
4179 fn reapplying_topic_with_new_id_drops_stale_id_index() {
4180 let mut image = MetadataImage::new(Uuid::nil());
4181 let old_id = Uuid::from_u128(1);
4182 let new_id = Uuid::from_u128(2);
4183 let topic = |id| {
4184 MetadataRecord::V1Topic(TopicRecord {
4185 name: "t".into(),
4186 topic_id: id,
4187 partitions: 1,
4188 replication_factor: 1,
4189 })
4190 };
4191 image.apply(&topic(old_id));
4192 assert2::assert!(image.topic_by_id(&old_id).is_some());
4193 image.apply(&topic(new_id));
4196 assert2::assert!(image.topic_by_id(&new_id).is_some());
4197 assert2::assert!(image.topic_by_id(&old_id).is_none());
4198 }
4199
4200 #[test]
4201 fn broker_throttle_rate_allows_zero() {
4202 let mut image = MetadataImage::new(Uuid::nil());
4205 image.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
4206 node_id: NodeId(3),
4207 config_name: "leader.replication.throttled.rate".into(),
4208 config_value: Some("0".into()),
4209 }));
4210 assert2::assert!(
4211 image.broker_throttle_rate(NodeId(3), ThrottleKind::Leader)
4212 == Some(<ByteRate as ByteRateExt>::ZERO)
4213 );
4214 }
4215
4216 #[test]
4217 fn dir_assignment_resizes_directories_to_replica_count() {
4218 let mut image = MetadataImage::new(Uuid::nil());
4224 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4225 topic: "t".into(),
4226 partition: 0,
4227 leader: NodeId(10),
4228 replicas: vec![NodeId(10), NodeId(20)],
4229 isr: vec![NodeId(10), NodeId(20)],
4230 directories: vec![], ..Default::default()
4232 }));
4233 let dir = Uuid::from_u128(0xD15);
4234 image.apply(&MetadataRecord::V1PartitionDirAssignment(
4235 PartitionDirAssignmentRecord {
4236 topic: "t".into(),
4237 partition: 0,
4238 replica: NodeId(10),
4239 directory: dir,
4240 },
4241 ));
4242 let pr = image.partition("t", 0).expect("partition present");
4243 assert2::assert!(pr.directories == vec![dir, MIGRATING]);
4245 }
4246
4247 #[test]
4248 fn dir_assignment_updates_existing_full_length_directories() {
4249 let mut image = MetadataImage::new(Uuid::nil());
4250 let old = Uuid::from_u128(0xAAA);
4251 image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4252 topic: "t".into(),
4253 partition: 0,
4254 leader: NodeId(10),
4255 replicas: vec![NodeId(10), NodeId(20)],
4256 isr: vec![NodeId(10), NodeId(20)],
4257 directories: vec![old, Uuid::nil()],
4258 ..Default::default()
4259 }));
4260 let dir = Uuid::from_u128(0xD15);
4261 image.apply(&MetadataRecord::V1PartitionDirAssignment(
4262 PartitionDirAssignmentRecord {
4263 topic: "t".into(),
4264 partition: 0,
4265 replica: NodeId(20),
4266 directory: dir,
4267 },
4268 ));
4269
4270 let pr = image.partition("t", 0).expect("partition present");
4271 assert2::assert!(pr.directories == vec![old, dir]);
4272 }
4273 fn freeze(scope: &str, pattern_type: PatternType) -> TopicFreezeRecord {
4276 TopicFreezeRecord {
4277 scope: scope.into(),
4278 pattern_type,
4279 frozen: true,
4280 reason: "DR cutover".into(),
4281 set_by: "User:alice".into(),
4282 set_at_ms: 1_700_000_000_000,
4283 proposal_id: Uuid::nil(),
4284 key_id: "alice-yubi".into(),
4285 signature: vec![0xAB; 64],
4286 }
4287 }
4288
4289 #[test]
4290 fn an_unfrozen_cluster_resolves_no_freeze() {
4291 check!(img().topic_freeze("orders").is_none());
4292 check!(img().topic_freezes().count() == 0);
4293 }
4294
4295 #[test]
4296 fn a_literal_freeze_beats_a_prefix_and_the_longest_prefix_wins() {
4297 let mut image = img();
4298 for (scope, pattern_type) in [
4299 ("tenant-a.", PatternType::Prefixed),
4300 ("tenant-a.orders.", PatternType::Prefixed),
4301 ("tenant-a.orders.eu", PatternType::Literal),
4302 ] {
4303 image.apply(&MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type)));
4304 }
4305
4306 for (label, topic, want) in [
4307 (
4308 "a literal entry beats every prefix entry",
4309 "tenant-a.orders.eu",
4310 Some("tenant-a.orders.eu"),
4311 ),
4312 (
4313 "the longest matching prefix wins",
4314 "tenant-a.orders.us",
4315 Some("tenant-a.orders."),
4316 ),
4317 (
4318 "a shorter prefix still matches",
4319 "tenant-a.billing",
4320 Some("tenant-a."),
4321 ),
4322 ("no scope covers the topic", "tenant-b.orders", None),
4323 ("a topic that sorts below every scope", "aaa", None),
4324 ] {
4325 check!(
4326 image.topic_freeze(topic).map(|f| f.scope.as_str()) == want,
4327 "{label}"
4328 );
4329 }
4330 }
4331
4332 #[test]
4333 fn an_internal_topic_is_never_frozen() {
4334 for (label, scope, pattern_type, covered) in [
4335 (
4336 "an empty prefix scope",
4337 "",
4338 PatternType::Prefixed,
4339 Some("orders"),
4340 ),
4341 (
4342 "a single-underscore prefix scope",
4343 "_",
4344 PatternType::Prefixed,
4345 Some("_metrics"),
4346 ),
4347 (
4348 "a literal internal name",
4349 "__consumer_offsets",
4350 PatternType::Literal,
4351 None,
4352 ),
4353 ] {
4354 let mut image = img();
4355 image.apply(&MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type)));
4356
4357 check!(
4358 image.topic_freeze("__consumer_offsets").is_none(),
4359 "{label}"
4360 );
4361 if let Some(topic) = covered {
4362 check!(image.topic_freeze(topic).is_some(), "{label}");
4365 }
4366 }
4367 }
4368
4369 #[test]
4370 fn a_thaw_removes_the_registry_entry() {
4371 for (label, pattern_type, topic) in [
4372 ("a literal scope", PatternType::Literal, "orders"),
4373 ("a prefixed scope", PatternType::Prefixed, "orders-eu"),
4374 ] {
4375 let mut image = img();
4376 let frozen = freeze("orders", pattern_type);
4377 image.apply(&MetadataRecord::V1TopicFreeze(frozen.clone()));
4378 check!(image.topic_freeze(topic).is_some(), "{label}");
4379
4380 image.apply(&MetadataRecord::V1TopicFreeze(TopicFreezeRecord {
4381 frozen: false,
4382 proposal_id: Uuid::from_u128(0xB1),
4383 ..frozen
4384 }));
4385 check!(image.topic_freeze(topic).is_none(), "{label}");
4386 check!(image.topic_freezes().count() == 0, "{label}");
4387 }
4388 }
4389
4390 #[test]
4391 fn a_later_freeze_replaces_the_entry_at_the_same_scope() {
4392 let mut image = img();
4393 image.apply(&MetadataRecord::V1TopicFreeze(freeze(
4394 "orders",
4395 PatternType::Literal,
4396 )));
4397 let replacement = TopicFreezeRecord {
4398 reason: "tenant offboarding".into(),
4399 set_by: "User:bob".into(),
4400 set_at_ms: 1_700_000_060_000,
4401 ..freeze("orders", PatternType::Literal)
4402 };
4403 image.apply(&MetadataRecord::V1TopicFreeze(replacement.clone()));
4404
4405 check!(image.topic_freeze("orders") == Some(&replacement));
4406 check!(image.topic_freezes().count() == 1);
4407 }
4408
4409 fn approval(principal: &str) -> BreakGlassApproval {
4412 BreakGlassApproval {
4413 principal: principal.into(),
4414 approved_at_ms: 1_700_000_060_000,
4415 key_id: format!("{principal}-yubi"),
4416 signature: vec![0xCD; 64],
4417 }
4418 }
4419
4420 fn proposal(id: Uuid) -> BreakGlassProposalRecord {
4421 BreakGlassProposalRecord {
4422 proposal_id: id,
4423 action: BreakGlassAction::ThawTopicFreeze,
4424 target: "literal:orders".into(),
4425 proposer: "User:alice".into(),
4426 reason: "incident 4711 closed".into(),
4427 created_at_ms: 1_700_000_000_000,
4428 expires_at_ms: 1_700_000_600_000,
4429 approvals: Vec::new(),
4430 consumed_at_ms: 0,
4431 withdrawn: false,
4432 }
4433 }
4434
4435 #[test]
4436 fn a_break_glass_proposal_upserts_and_deletes() {
4437 let id = Uuid::from_u128(0xB1);
4438 let mut image = img();
4439 let record = proposal(id);
4440 image.apply(&MetadataRecord::V1BreakGlassProposal(record.clone()));
4441 check!(image.break_glass_proposal(id) == Some(&record));
4442 check!(image.break_glass_proposals().count() == 1);
4443
4444 let approved = BreakGlassProposalRecord {
4445 approvals: vec![approval("User:bob")],
4446 ..record
4447 };
4448 image.apply(&MetadataRecord::V1BreakGlassProposal(approved.clone()));
4449 check!(image.break_glass_proposal(id) == Some(&approved));
4450
4451 image.apply(&MetadataRecord::V1DeleteBreakGlassProposal(id));
4452 check!(image.break_glass_proposal(id).is_none());
4453 check!(image.break_glass_proposals().count() == 0);
4454 }
4455
4456 #[test]
4457 fn to_records_preserves_both_freeze_registries_and_the_proposals() {
4458 let cid = Uuid::new_v4();
4459 let mut image = MetadataImage::new(cid);
4460 let literal = freeze("orders", PatternType::Literal);
4461 let prefixed = freeze("tenant-a.", PatternType::Prefixed);
4462 let pending = BreakGlassProposalRecord {
4463 approvals: vec![approval("User:bob")],
4464 ..proposal(Uuid::from_u128(0xB1))
4465 };
4466 image.apply(&MetadataRecord::V1TopicFreeze(literal.clone()));
4467 image.apply(&MetadataRecord::V1TopicFreeze(prefixed.clone()));
4468 image.apply(&MetadataRecord::V1BreakGlassProposal(pending.clone()));
4469
4470 let rebuilt = MetadataImage::from_records(cid, &image.to_records());
4471
4472 check!(rebuilt == image);
4473 check!(rebuilt.topic_freeze("orders") == Some(&literal));
4474 check!(rebuilt.topic_freeze("tenant-a.billing") == Some(&prefixed));
4475 check!(rebuilt.break_glass_proposal(pending.proposal_id) == Some(&pending));
4476 }
4477
4478 #[test]
4481 fn validate_rejects_only_an_empty_freeze_scope() {
4482 let image = img();
4483 for (label, scope, pattern_type, want) in [
4484 (
4485 "a literal scope naming a topic that does not exist",
4486 "orders",
4487 PatternType::Literal,
4488 Ok(()),
4489 ),
4490 (
4491 "a prefix scope, which names no topic by construction",
4492 "tenant-a.",
4493 PatternType::Prefixed,
4494 Ok(()),
4495 ),
4496 (
4497 "an empty literal scope",
4498 "",
4499 PatternType::Literal,
4500 Err(MetadataError::EmptyFreezeScope),
4501 ),
4502 (
4503 "an empty prefix scope",
4504 "",
4505 PatternType::Prefixed,
4506 Err(MetadataError::EmptyFreezeScope),
4507 ),
4508 ] {
4509 let rec = MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type));
4510 check!(image.validate(&rec) == want, "{label}");
4511 }
4512 }
4513
4514 #[test]
4515 fn validate_accepts_a_break_glass_proposal_that_is_not_yet_stored() {
4516 let image = img();
4517 let rec = MetadataRecord::V1BreakGlassProposal(proposal(Uuid::from_u128(0xB1)));
4518 check!(image.validate(&rec) == Ok(()));
4519 }
4520
4521 #[test]
4522 fn validate_accepts_only_an_appended_approval() {
4523 let id = Uuid::from_u128(0xB1);
4524 let (alice, bob, carol) = (
4525 approval("User:alice"),
4526 approval("User:bob"),
4527 approval("User:carol"),
4528 );
4529 let mut image = img();
4530 let stored = BreakGlassProposalRecord {
4531 approvals: vec![alice.clone(), bob.clone()],
4532 ..proposal(id)
4533 };
4534 image.apply(&MetadataRecord::V1BreakGlassProposal(stored.clone()));
4535
4536 for (label, approvals, want) in [
4537 (
4538 "the stored list unchanged, which a consumption writes back",
4539 vec![alice.clone(), bob.clone()],
4540 Ok(()),
4541 ),
4542 (
4543 "one appended approval",
4544 vec![alice.clone(), bob.clone(), carol.clone()],
4545 Ok(()),
4546 ),
4547 (
4548 "a reordered list",
4549 vec![bob, alice.clone()],
4550 Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4551 ),
4552 (
4553 "a truncated list",
4554 vec![alice.clone()],
4555 Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4556 ),
4557 (
4558 "an empty list",
4559 Vec::new(),
4560 Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4561 ),
4562 (
4563 "a replaced approval",
4564 vec![alice, carol],
4565 Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4566 ),
4567 ] {
4568 let candidate = MetadataRecord::V1BreakGlassProposal(BreakGlassProposalRecord {
4569 approvals,
4570 ..stored.clone()
4571 });
4572 check!(image.validate(&candidate) == want, "{label}");
4573 }
4574 }
4575
4576 #[test]
4580 fn a_concurrent_approval_cannot_drop_the_stored_one() {
4581 let id = Uuid::from_u128(0xB1);
4582 let mut image = img();
4583 image.apply(&MetadataRecord::V1BreakGlassProposal(proposal(id)));
4584
4585 let by_bob = BreakGlassProposalRecord {
4586 approvals: vec![approval("User:bob")],
4587 ..proposal(id)
4588 };
4589 let by_carol = BreakGlassProposalRecord {
4590 approvals: vec![approval("User:carol")],
4591 ..proposal(id)
4592 };
4593 check!(image.validate(&MetadataRecord::V1BreakGlassProposal(by_bob.clone())) == Ok(()));
4594 image.apply(&MetadataRecord::V1BreakGlassProposal(by_bob));
4595
4596 check!(
4597 image.validate(&MetadataRecord::V1BreakGlassProposal(by_carol))
4598 == Err(MetadataError::BreakGlassApprovalsNotAnExtension(id))
4599 );
4600
4601 let both = BreakGlassProposalRecord {
4602 approvals: vec![approval("User:bob"), approval("User:carol")],
4603 ..proposal(id)
4604 };
4605 check!(image.validate(&MetadataRecord::V1BreakGlassProposal(both)) == Ok(()));
4606 }
4607
4608 #[test]
4609 fn validate_rejects_every_change_to_a_settled_proposal() {
4610 let id = Uuid::from_u128(0xB1);
4611 for (label, settled, want) in [
4612 (
4613 "a consumed proposal",
4614 BreakGlassProposalRecord {
4615 consumed_at_ms: 1_700_000_120_000,
4616 ..proposal(id)
4617 },
4618 MetadataError::BreakGlassProposalConsumed(id),
4619 ),
4620 (
4621 "a withdrawn proposal",
4622 BreakGlassProposalRecord {
4623 withdrawn: true,
4624 ..proposal(id)
4625 },
4626 MetadataError::BreakGlassProposalWithdrawn(id),
4627 ),
4628 ] {
4629 let mut image = img();
4630 image.apply(&MetadataRecord::V1BreakGlassProposal(settled.clone()));
4631
4632 for (change_label, candidate) in [
4633 (
4634 "an appended approval",
4635 BreakGlassProposalRecord {
4636 approvals: vec![approval("User:bob")],
4637 ..settled.clone()
4638 },
4639 ),
4640 (
4641 "a second consumption",
4642 BreakGlassProposalRecord {
4643 consumed_at_ms: 1_700_000_180_000,
4644 ..settled.clone()
4645 },
4646 ),
4647 ("the record unchanged", settled.clone()),
4648 ] {
4649 let rec = MetadataRecord::V1BreakGlassProposal(candidate);
4650 check!(
4651 image.validate(&rec) == Err(want.clone()),
4652 "{label}, {change_label}"
4653 );
4654 }
4655 }
4656 }
4657
4658 #[test]
4659 fn validate_accepts_a_break_glass_tombstone_for_any_id() {
4660 let image = img();
4661 let rec = MetadataRecord::V1DeleteBreakGlassProposal(Uuid::from_u128(0xB1));
4662 check!(image.validate(&rec) == Ok(()));
4663 }
4664}