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