1use std::sync::{Arc, OnceLock};
23
24use crate::runtime::audit_log::{
25 AuditAuthSource, AuditEvent, AuditField, AuditFieldEscaper, AuditLogger, Outcome,
26};
27
28static GLOBAL_SINK: OnceLock<Arc<AuditLogger>> = OnceLock::new();
48
49pub fn install_global_audit_sink(logger: Arc<AuditLogger>) {
55 let _ = GLOBAL_SINK.set(logger);
56}
57
58#[derive(Debug)]
68pub enum OperatorEvent {
69 ReplicationBroken { peer: String, reason: String },
71 Divergence {
74 peer: String,
75 leader_lsn: u64,
76 follower_lsn: u64,
77 },
78 WalFsyncFailed { path: String, error: String },
80 DiskSpaceCritical {
82 path: String,
83 available_bytes: u64,
84 threshold_bytes: u64,
85 },
86 AuthBypass {
89 principal: String,
90 resource: String,
91 detail: String,
92 },
93 AdminCapabilityGranted {
95 granted_to: String,
96 capability: String,
97 granted_by: String,
98 },
99 SecretRotationFailed { secret_ref: String, error: String },
101 ConfigChanged {
103 key: String,
104 old_value: String,
105 new_value: String,
106 changed_by: String,
107 },
108 FailoverProfileChanged {
110 old_profile: String,
111 new_profile: String,
112 changed_by: String,
113 },
114 StartupFailed { phase: String, error: String },
116 ShutdownForced { reason: String },
119 SchemaCorruption { collection: String, detail: String },
121 CheckpointFailed { lsn: u64, error: String },
123 DanglingAdminIntent {
127 id: crate::crypto::uuid::Uuid,
128 op: crate::telemetry::admin_intent_log::IntentOp,
129 started_at_ms: u64,
131 last_phase: crate::telemetry::admin_intent_log::IntentPhase,
132 },
133 ConfigChangeRequiresRestart { fields_changed: String },
137 SubscriptionSchemaChange {
141 collection: String,
142 subscription_names: String,
144 fields_added: String,
146 fields_removed: String,
148 lsn: u64,
149 },
150 OutboxDlqActivated {
153 queue: String,
154 dlq: String,
155 reason: String,
156 },
157 QueueDlqPromoted {
161 queue: String,
162 group: String,
163 dlq: String,
164 message_id: u64,
165 attempts: u32,
166 reason: String,
167 },
168 DeposedPrimaryRollback {
179 common_point_lsn: u64,
182 tail_to_lsn: u64,
184 tail_lsns: u64,
186 commit_watermark: u64,
190 rollback_file: String,
192 new_primary_addr: String,
194 new_term: u64,
196 },
197 OwnershipFenced {
200 reason: String,
201 ownership_epoch: u64,
202 range_identity: String,
203 },
204 CooperativeLeaseHandoff {
208 range_identity: String,
209 previous_owner: String,
210 new_owner: String,
211 previous_epoch: u64,
212 new_epoch: u64,
213 commit_watermark: u64,
214 target_lsn: u64,
215 },
216 ReplicaAuthorityDivergence {
219 range_identity: String,
220 expected_term: u64,
221 expected_ownership_epoch: u64,
222 observed_term: u64,
223 observed_ownership_epoch: u64,
224 lsn: u64,
225 },
226}
227
228impl OperatorEvent {
229 pub fn all_variant_names() -> &'static [&'static str] {
231 &[
232 "ReplicationBroken",
233 "Divergence",
234 "WalFsyncFailed",
235 "DiskSpaceCritical",
236 "AuthBypass",
237 "AdminCapabilityGranted",
238 "SecretRotationFailed",
239 "ConfigChanged",
240 "FailoverProfileChanged",
241 "StartupFailed",
242 "ShutdownForced",
243 "SchemaCorruption",
244 "CheckpointFailed",
245 "DanglingAdminIntent",
246 "ConfigChangeRequiresRestart",
247 "SubscriptionSchemaChange",
248 "OutboxDlqActivated",
249 "QueueDlqPromoted",
250 "DeposedPrimaryRollback",
251 "OwnershipFenced",
252 "CooperativeLeaseHandoff",
253 "ReplicaAuthorityDivergence",
254 ]
255 }
256
257 pub(super) fn variant_name(&self) -> &'static str {
259 match self {
260 Self::ReplicationBroken { .. } => "ReplicationBroken",
261 Self::Divergence { .. } => "Divergence",
262 Self::WalFsyncFailed { .. } => "WalFsyncFailed",
263 Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
264 Self::AuthBypass { .. } => "AuthBypass",
265 Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
266 Self::SecretRotationFailed { .. } => "SecretRotationFailed",
267 Self::ConfigChanged { .. } => "ConfigChanged",
268 Self::FailoverProfileChanged { .. } => "FailoverProfileChanged",
269 Self::StartupFailed { .. } => "StartupFailed",
270 Self::ShutdownForced { .. } => "ShutdownForced",
271 Self::SchemaCorruption { .. } => "SchemaCorruption",
272 Self::CheckpointFailed { .. } => "CheckpointFailed",
273 Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
274 Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
275 Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
276 Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
277 Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
278 Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
279 Self::OwnershipFenced { .. } => "OwnershipFenced",
280 Self::CooperativeLeaseHandoff { .. } => "CooperativeLeaseHandoff",
281 Self::ReplicaAuthorityDivergence { .. } => "ReplicaAuthorityDivergence",
282 }
283 }
284
285 pub fn emit_global(self) {
299 match GLOBAL_SINK.get() {
300 Some(logger) => self.emit(logger.as_ref()),
301 None => {
302 let (_, _, summary) = self.decompose();
303 tracing::warn!(target: "reddb::operator", "{summary}");
304 eprintln!("[reddb::operator] (no audit sink) {summary}");
305 }
306 }
307 }
308
309 pub fn emit(self, audit: &AuditLogger) {
310 let (action, fields, summary) = self.decompose();
311
312 let ev = AuditEvent::builder(action)
313 .source(AuditAuthSource::System)
314 .outcome(Outcome::Error)
315 .fields(fields)
316 .build();
317
318 let audit_ok = {
320 audit.record_event(ev);
324 true
325 };
326
327 tracing::warn!(target: "reddb::operator", "{summary}");
329
330 if !audit_ok {
333 eprintln!("[reddb::operator] {summary}");
334 }
335 }
336
337 pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
339 match self {
340 Self::ReplicationBroken { peer, reason } => {
341 let summary = format!("replication broken: peer={peer} reason={reason}");
342 let fields = vec![
343 AuditFieldEscaper::field("peer", peer),
344 AuditFieldEscaper::field("reason", reason),
345 ];
346 ("operator/replication_broken", fields, summary)
347 }
348 Self::Divergence {
349 peer,
350 leader_lsn,
351 follower_lsn,
352 } => {
353 let summary = format!(
354 "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
355 );
356 let fields = vec![
357 AuditFieldEscaper::field("peer", peer),
358 AuditFieldEscaper::field("leader_lsn", leader_lsn),
359 AuditFieldEscaper::field("follower_lsn", follower_lsn),
360 ];
361 ("operator/divergence", fields, summary)
362 }
363 Self::WalFsyncFailed { path, error } => {
364 let summary = format!("WAL fsync failed: path={path} error={error}");
365 let fields = vec![
366 AuditFieldEscaper::field("path", path),
367 AuditFieldEscaper::field("error", error),
368 ];
369 ("operator/wal_fsync_failed", fields, summary)
370 }
371 Self::DiskSpaceCritical {
372 path,
373 available_bytes,
374 threshold_bytes,
375 } => {
376 let summary = format!(
377 "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
378 );
379 let fields = vec![
380 AuditFieldEscaper::field("path", path),
381 AuditFieldEscaper::field("available_bytes", available_bytes),
382 AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
383 ];
384 ("operator/disk_space_critical", fields, summary)
385 }
386 Self::AuthBypass {
387 principal,
388 resource,
389 detail,
390 } => {
391 let summary =
392 format!("auth bypass detected: principal={principal} resource={resource}");
393 let fields = vec![
394 AuditFieldEscaper::field("principal", principal),
395 AuditFieldEscaper::field("resource", resource),
396 AuditFieldEscaper::field("detail", detail),
397 ];
398 ("operator/auth_bypass", fields, summary)
399 }
400 Self::AdminCapabilityGranted {
401 granted_to,
402 capability,
403 granted_by,
404 } => {
405 let summary = format!(
406 "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
407 );
408 let fields = vec![
409 AuditFieldEscaper::field("granted_to", granted_to),
410 AuditFieldEscaper::field("capability", capability),
411 AuditFieldEscaper::field("granted_by", granted_by),
412 ];
413 ("operator/admin_capability_granted", fields, summary)
414 }
415 Self::SecretRotationFailed { secret_ref, error } => {
416 let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
417 let fields = vec![
418 AuditFieldEscaper::field("secret_ref", secret_ref),
419 AuditFieldEscaper::field("error", error),
420 ];
421 ("operator/secret_rotation_failed", fields, summary)
422 }
423 Self::ConfigChanged {
424 key,
425 old_value,
426 new_value,
427 changed_by,
428 } => {
429 let summary = format!(
430 "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
431 );
432 let fields = vec![
433 AuditFieldEscaper::field("key", key),
434 AuditFieldEscaper::field("old_value", old_value),
435 AuditFieldEscaper::field("new_value", new_value),
436 AuditFieldEscaper::field("changed_by", changed_by),
437 ];
438 ("operator/config_changed", fields, summary)
439 }
440 Self::FailoverProfileChanged {
441 old_profile,
442 new_profile,
443 changed_by,
444 } => {
445 let summary = format!(
446 "failover profile changed: old={old_profile} new={new_profile} by={changed_by}"
447 );
448 let fields = vec![
449 AuditFieldEscaper::field("old_profile", old_profile),
450 AuditFieldEscaper::field("new_profile", new_profile),
451 AuditFieldEscaper::field("changed_by", changed_by),
452 ];
453 ("operator/failover_profile_changed", fields, summary)
454 }
455 Self::StartupFailed { phase, error } => {
456 let summary = format!("startup failed: phase={phase} error={error}");
457 let fields = vec![
458 AuditFieldEscaper::field("phase", phase),
459 AuditFieldEscaper::field("error", error),
460 ];
461 ("operator/startup_failed", fields, summary)
462 }
463 Self::ShutdownForced { reason } => {
464 let summary = format!("shutdown forced: reason={reason}");
465 let fields = vec![AuditFieldEscaper::field("reason", reason)];
466 ("operator/shutdown_forced", fields, summary)
467 }
468 Self::SchemaCorruption { collection, detail } => {
469 let summary = format!("schema corruption: collection={collection} detail={detail}");
470 let fields = vec![
471 AuditFieldEscaper::field("collection", collection),
472 AuditFieldEscaper::field("detail", detail),
473 ];
474 ("operator/schema_corruption", fields, summary)
475 }
476 Self::CheckpointFailed { lsn, error } => {
477 let summary = format!("checkpoint failed: lsn={lsn} error={error}");
478 let fields = vec![
479 AuditFieldEscaper::field("lsn", lsn),
480 AuditFieldEscaper::field("error", error),
481 ];
482 ("operator/checkpoint_failed", fields, summary)
483 }
484 Self::DanglingAdminIntent {
485 id,
486 op,
487 started_at_ms,
488 last_phase,
489 } => {
490 let summary = format!(
491 "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
492 );
493 let fields = vec![
494 AuditFieldEscaper::field("id", id.to_string()),
495 AuditFieldEscaper::field("op", op.to_string()),
496 AuditFieldEscaper::field("started_at_ms", started_at_ms),
497 AuditFieldEscaper::field("last_phase", last_phase.to_string()),
498 ];
499 ("operator/dangling_admin_intent", fields, summary)
500 }
501 Self::ConfigChangeRequiresRestart { fields_changed } => {
502 let summary = format!("config change requires restart: fields={fields_changed}");
503 let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
504 ("operator/config_change_requires_restart", fields, summary)
505 }
506 Self::SubscriptionSchemaChange {
507 collection,
508 subscription_names,
509 fields_added,
510 fields_removed,
511 lsn,
512 } => {
513 let summary = format!(
514 "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
515 );
516 let fields = vec![
517 AuditFieldEscaper::field("collection", collection),
518 AuditFieldEscaper::field("subscription_names", subscription_names),
519 AuditFieldEscaper::field("fields_added", fields_added),
520 AuditFieldEscaper::field("fields_removed", fields_removed),
521 AuditFieldEscaper::field("lsn", lsn),
522 ];
523 ("operator/subscription_schema_change", fields, summary)
524 }
525 Self::OutboxDlqActivated { queue, dlq, reason } => {
526 let summary =
527 format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
528 let fields = vec![
529 AuditFieldEscaper::field("queue", queue),
530 AuditFieldEscaper::field("dlq", dlq),
531 AuditFieldEscaper::field("reason", reason),
532 ];
533 ("operator/outbox_dlq_activated", fields, summary)
534 }
535 Self::QueueDlqPromoted {
536 queue,
537 group,
538 dlq,
539 message_id,
540 attempts,
541 reason,
542 } => {
543 let summary = format!(
544 "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
545 );
546 let fields = vec![
547 AuditFieldEscaper::field("queue", queue),
548 AuditFieldEscaper::field("group", group),
549 AuditFieldEscaper::field("dlq", dlq),
550 AuditFieldEscaper::field("message_id", message_id),
551 AuditFieldEscaper::field("attempts", attempts as u64),
552 AuditFieldEscaper::field("reason", reason),
553 ];
554 ("operator/queue_dlq_promoted", fields, summary)
555 }
556 Self::DeposedPrimaryRollback {
557 common_point_lsn,
558 tail_to_lsn,
559 tail_lsns,
560 commit_watermark,
561 rollback_file,
562 new_primary_addr,
563 new_term,
564 } => {
565 let summary = format!(
566 "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
567 (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
568 up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
569 {new_primary_addr} under term {new_term}"
570 );
571 let fields = vec![
572 AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
573 AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
574 AuditFieldEscaper::field("tail_lsns", tail_lsns),
575 AuditFieldEscaper::field("commit_watermark", commit_watermark),
576 AuditFieldEscaper::field("rollback_file", rollback_file),
577 AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
578 AuditFieldEscaper::field("new_term", new_term),
579 ];
580 ("operator/deposed_primary_rollback", fields, summary)
581 }
582 Self::OwnershipFenced {
583 reason,
584 ownership_epoch,
585 range_identity,
586 } => {
587 let summary = format!(
588 "ownership fenced: reason={reason} ownership_epoch={ownership_epoch} range={range_identity}"
589 );
590 let fields = vec![
591 AuditFieldEscaper::field("reason", reason),
592 AuditFieldEscaper::field("ownership_epoch", ownership_epoch),
593 AuditFieldEscaper::field("range_identity", range_identity),
594 ];
595 ("operator/ownership_fenced", fields, summary)
596 }
597 Self::CooperativeLeaseHandoff {
598 range_identity,
599 previous_owner,
600 new_owner,
601 previous_epoch,
602 new_epoch,
603 commit_watermark,
604 target_lsn,
605 } => {
606 let summary = format!(
607 "cooperative lease handoff: range={range_identity} previous_owner={previous_owner} \
608 new_owner={new_owner} epoch={previous_epoch}->{new_epoch} \
609 commit_watermark={commit_watermark} target_lsn={target_lsn}"
610 );
611 let fields = vec![
612 AuditFieldEscaper::field("range_identity", range_identity),
613 AuditFieldEscaper::field("previous_owner", previous_owner),
614 AuditFieldEscaper::field("new_owner", new_owner),
615 AuditFieldEscaper::field("previous_epoch", previous_epoch),
616 AuditFieldEscaper::field("new_epoch", new_epoch),
617 AuditFieldEscaper::field("commit_watermark", commit_watermark),
618 AuditFieldEscaper::field("target_lsn", target_lsn),
619 ];
620 ("operator/cooperative_lease_handoff", fields, summary)
621 }
622 Self::ReplicaAuthorityDivergence {
623 range_identity,
624 expected_term,
625 expected_ownership_epoch,
626 observed_term,
627 observed_ownership_epoch,
628 lsn,
629 } => {
630 let summary = format!(
631 "replica authority divergence: range={range_identity} lsn={lsn} \
632 expected=({expected_term},{expected_ownership_epoch}) \
633 observed=({observed_term},{observed_ownership_epoch})"
634 );
635 let fields = vec![
636 AuditFieldEscaper::field("range_identity", range_identity),
637 AuditFieldEscaper::field("expected_term", expected_term),
638 AuditFieldEscaper::field("expected_ownership_epoch", expected_ownership_epoch),
639 AuditFieldEscaper::field("observed_term", observed_term),
640 AuditFieldEscaper::field("observed_ownership_epoch", observed_ownership_epoch),
641 AuditFieldEscaper::field("lsn", lsn),
642 ];
643 ("operator/replica_authority_divergence", fields, summary)
644 }
645 }
646 }
647}
648
649#[cfg(test)]
654mod tests {
655 use std::time::Duration;
656
657 use super::*;
658 use crate::runtime::audit_log::AuditLogger;
659
660 struct TempAuditPath(std::path::PathBuf);
661
662 impl std::ops::Deref for TempAuditPath {
663 type Target = std::path::PathBuf;
664
665 fn deref(&self) -> &std::path::PathBuf {
666 &self.0
667 }
668 }
669
670 impl AsRef<std::path::Path> for TempAuditPath {
671 fn as_ref(&self) -> &std::path::Path {
672 &self.0
673 }
674 }
675
676 impl Drop for TempAuditPath {
677 fn drop(&mut self) {
678 if let Some(parent) = self.0.parent() {
679 let _ = std::fs::remove_dir_all(parent);
680 }
681 }
682 }
683
684 fn make_logger() -> (AuditLogger, TempAuditPath) {
685 let dir = std::env::temp_dir().join(format!(
686 "reddb-op-event-{}-{}",
687 std::process::id(),
688 crate::utils::now_unix_nanos()
689 ));
690 std::fs::create_dir_all(&dir).unwrap();
691 let path = dir.join(".audit.log");
692 let logger = AuditLogger::with_path(path.clone());
693 (logger, TempAuditPath(path))
694 }
695
696 fn drain(logger: &AuditLogger) {
697 assert!(
698 logger.wait_idle(Duration::from_secs(2)),
699 "audit logger drain timed out"
700 );
701 }
702
703 fn read_last_line(path: &std::path::Path) -> crate::json::Value {
704 let body = std::fs::read_to_string(path).unwrap();
705 let line = body.lines().last().expect("at least one audit line");
706 crate::json::from_str(line).expect("valid JSON")
707 }
708
709 #[test]
714 fn replication_broken_emits() {
715 let (logger, path) = make_logger();
716 OperatorEvent::ReplicationBroken {
717 peer: "replica-1".into(),
718 reason: "TCP reset".into(),
719 }
720 .emit(&logger);
721 drain(&logger);
722 let v = read_last_line(&path);
723 assert_eq!(
724 v.get("action").and_then(|x| x.as_str()),
725 Some("operator/replication_broken")
726 );
727 let peer = v
728 .get("detail")
729 .and_then(|d| d.get("peer"))
730 .and_then(|x| x.as_str());
731 assert_eq!(peer, Some("replica-1"));
732 }
733
734 #[test]
735 fn divergence_emits() {
736 let (logger, path) = make_logger();
737 OperatorEvent::Divergence {
738 peer: "replica-2".into(),
739 leader_lsn: 1000,
740 follower_lsn: 999,
741 }
742 .emit(&logger);
743 drain(&logger);
744 let v = read_last_line(&path);
745 assert_eq!(
746 v.get("action").and_then(|x| x.as_str()),
747 Some("operator/divergence")
748 );
749 let lsn = v
750 .get("detail")
751 .and_then(|d| d.get("leader_lsn"))
752 .and_then(|x| x.as_i64());
753 assert_eq!(lsn, Some(1000));
754 }
755
756 #[test]
757 fn wal_fsync_failed_emits() {
758 let (logger, path) = make_logger();
759 OperatorEvent::WalFsyncFailed {
760 path: "/data/wal".into(),
761 error: "EIO".into(),
762 }
763 .emit(&logger);
764 drain(&logger);
765 let v = read_last_line(&path);
766 assert_eq!(
767 v.get("action").and_then(|x| x.as_str()),
768 Some("operator/wal_fsync_failed")
769 );
770 let err = v
771 .get("detail")
772 .and_then(|d| d.get("error"))
773 .and_then(|x| x.as_str());
774 assert_eq!(err, Some("EIO"));
775 }
776
777 #[test]
778 fn disk_space_critical_emits() {
779 let (logger, path) = make_logger();
780 OperatorEvent::DiskSpaceCritical {
781 path: "/data".into(),
782 available_bytes: 1024,
783 threshold_bytes: 10240,
784 }
785 .emit(&logger);
786 drain(&logger);
787 let v = read_last_line(&path);
788 assert_eq!(
789 v.get("action").and_then(|x| x.as_str()),
790 Some("operator/disk_space_critical")
791 );
792 let avail = v
793 .get("detail")
794 .and_then(|d| d.get("available_bytes"))
795 .and_then(|x| x.as_i64());
796 assert_eq!(avail, Some(1024));
797 }
798
799 #[test]
800 fn auth_bypass_emits() {
801 let (logger, path) = make_logger();
802 OperatorEvent::AuthBypass {
803 principal: "alice".into(),
804 resource: "/admin/drop".into(),
805 detail: "scope check skipped".into(),
806 }
807 .emit(&logger);
808 drain(&logger);
809 let v = read_last_line(&path);
810 assert_eq!(
811 v.get("action").and_then(|x| x.as_str()),
812 Some("operator/auth_bypass")
813 );
814 let res = v
815 .get("detail")
816 .and_then(|d| d.get("resource"))
817 .and_then(|x| x.as_str());
818 assert_eq!(res, Some("/admin/drop"));
819 }
820
821 #[test]
822 fn admin_capability_granted_emits() {
823 let (logger, path) = make_logger();
824 OperatorEvent::AdminCapabilityGranted {
825 granted_to: "bob".into(),
826 capability: "ADMIN_WRITE".into(),
827 granted_by: "root".into(),
828 }
829 .emit(&logger);
830 drain(&logger);
831 let v = read_last_line(&path);
832 assert_eq!(
833 v.get("action").and_then(|x| x.as_str()),
834 Some("operator/admin_capability_granted")
835 );
836 let cap = v
837 .get("detail")
838 .and_then(|d| d.get("capability"))
839 .and_then(|x| x.as_str());
840 assert_eq!(cap, Some("ADMIN_WRITE"));
841 }
842
843 #[test]
844 fn secret_rotation_failed_emits() {
845 let (logger, path) = make_logger();
846 OperatorEvent::SecretRotationFailed {
847 secret_ref: "jwt-signing-key".into(),
848 error: "HSM unreachable".into(),
849 }
850 .emit(&logger);
851 drain(&logger);
852 let v = read_last_line(&path);
853 assert_eq!(
854 v.get("action").and_then(|x| x.as_str()),
855 Some("operator/secret_rotation_failed")
856 );
857 let r = v
858 .get("detail")
859 .and_then(|d| d.get("secret_ref"))
860 .and_then(|x| x.as_str());
861 assert_eq!(r, Some("jwt-signing-key"));
862 }
863
864 #[test]
865 fn config_changed_emits() {
866 let (logger, path) = make_logger();
867 OperatorEvent::ConfigChanged {
868 key: "max_connections".into(),
869 old_value: "100".into(),
870 new_value: "200".into(),
871 changed_by: "ops-bot".into(),
872 }
873 .emit(&logger);
874 drain(&logger);
875 let v = read_last_line(&path);
876 assert_eq!(
877 v.get("action").and_then(|x| x.as_str()),
878 Some("operator/config_changed")
879 );
880 let nv = v
881 .get("detail")
882 .and_then(|d| d.get("new_value"))
883 .and_then(|x| x.as_str());
884 assert_eq!(nv, Some("200"));
885 }
886
887 #[test]
888 fn failover_profile_changed_emits() {
889 let (logger, path) = make_logger();
890 OperatorEvent::FailoverProfileChanged {
891 old_profile: "balanced".into(),
892 new_profile: "aggressive".into(),
893 changed_by: "operator".into(),
894 }
895 .emit(&logger);
896 drain(&logger);
897 let v = read_last_line(&path);
898 assert_eq!(
899 v.get("action").and_then(|x| x.as_str()),
900 Some("operator/failover_profile_changed")
901 );
902 let new_profile = v
903 .get("detail")
904 .and_then(|d| d.get("new_profile"))
905 .and_then(|x| x.as_str());
906 assert_eq!(new_profile, Some("aggressive"));
907 }
908
909 #[test]
910 fn startup_failed_emits() {
911 let (logger, path) = make_logger();
912 OperatorEvent::StartupFailed {
913 phase: "wal_recovery".into(),
914 error: "corrupt frame".into(),
915 }
916 .emit(&logger);
917 drain(&logger);
918 let v = read_last_line(&path);
919 assert_eq!(
920 v.get("action").and_then(|x| x.as_str()),
921 Some("operator/startup_failed")
922 );
923 let phase = v
924 .get("detail")
925 .and_then(|d| d.get("phase"))
926 .and_then(|x| x.as_str());
927 assert_eq!(phase, Some("wal_recovery"));
928 }
929
930 #[test]
931 fn shutdown_forced_emits() {
932 let (logger, path) = make_logger();
933 OperatorEvent::ShutdownForced {
934 reason: "OOM".into(),
935 }
936 .emit(&logger);
937 drain(&logger);
938 let v = read_last_line(&path);
939 assert_eq!(
940 v.get("action").and_then(|x| x.as_str()),
941 Some("operator/shutdown_forced")
942 );
943 let r = v
944 .get("detail")
945 .and_then(|d| d.get("reason"))
946 .and_then(|x| x.as_str());
947 assert_eq!(r, Some("OOM"));
948 }
949
950 #[test]
951 fn schema_corruption_emits() {
952 let (logger, path) = make_logger();
953 OperatorEvent::SchemaCorruption {
954 collection: "users".into(),
955 detail: "unknown type tag 0xFF".into(),
956 }
957 .emit(&logger);
958 drain(&logger);
959 let v = read_last_line(&path);
960 assert_eq!(
961 v.get("action").and_then(|x| x.as_str()),
962 Some("operator/schema_corruption")
963 );
964 let coll = v
965 .get("detail")
966 .and_then(|d| d.get("collection"))
967 .and_then(|x| x.as_str());
968 assert_eq!(coll, Some("users"));
969 }
970
971 #[test]
972 fn checkpoint_failed_emits() {
973 let (logger, path) = make_logger();
974 OperatorEvent::CheckpointFailed {
975 lsn: 42_000,
976 error: "write stall".into(),
977 }
978 .emit(&logger);
979 drain(&logger);
980 let v = read_last_line(&path);
981 assert_eq!(
982 v.get("action").and_then(|x| x.as_str()),
983 Some("operator/checkpoint_failed")
984 );
985 let lsn = v
986 .get("detail")
987 .and_then(|d| d.get("lsn"))
988 .and_then(|x| x.as_i64());
989 assert_eq!(lsn, Some(42_000));
990 }
991
992 #[test]
993 fn deposed_primary_rollback_emits() {
994 let (logger, path) = make_logger();
995 OperatorEvent::DeposedPrimaryRollback {
996 common_point_lsn: 200,
997 tail_to_lsn: 230,
998 tail_lsns: 30,
999 commit_watermark: 200,
1000 rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
1001 new_primary_addr: "http://node-b:55055".into(),
1002 new_term: 8,
1003 }
1004 .emit(&logger);
1005 drain(&logger);
1006 let v = read_last_line(&path);
1007 assert_eq!(
1008 v.get("action").and_then(|x| x.as_str()),
1009 Some("operator/deposed_primary_rollback")
1010 );
1011 let cp = v
1012 .get("detail")
1013 .and_then(|d| d.get("common_point_lsn"))
1014 .and_then(|x| x.as_i64());
1015 assert_eq!(cp, Some(200));
1016 let file = v
1017 .get("detail")
1018 .and_then(|d| d.get("rollback_file"))
1019 .and_then(|x| x.as_str());
1020 assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
1021 }
1022
1023 #[test]
1024 fn ownership_fenced_emits_reason_epoch_and_range() {
1025 let (logger, path) = make_logger();
1026 OperatorEvent::OwnershipFenced {
1027 reason: "stale_ownership".into(),
1028 ownership_epoch: 2,
1029 range_identity: "system.global/0".into(),
1030 }
1031 .emit(&logger);
1032 drain(&logger);
1033 let v = read_last_line(&path);
1034 assert_eq!(
1035 v.get("action").and_then(|x| x.as_str()),
1036 Some("operator/ownership_fenced")
1037 );
1038 let detail = v.get("detail").expect("event detail");
1039 assert_eq!(
1040 detail.get("reason").and_then(|x| x.as_str()),
1041 Some("stale_ownership")
1042 );
1043 assert_eq!(
1044 detail.get("ownership_epoch").and_then(|x| x.as_i64()),
1045 Some(2)
1046 );
1047 assert_eq!(
1048 detail.get("range_identity").and_then(|x| x.as_str()),
1049 Some("system.global/0")
1050 );
1051 }
1052
1053 #[test]
1054 fn cooperative_lease_handoff_emits_epoch_watermark_and_owners() {
1055 let (logger, path) = make_logger();
1056 OperatorEvent::CooperativeLeaseHandoff {
1057 range_identity: "system.global/0".into(),
1058 previous_owner: "CN=node-a,O=reddb".into(),
1059 new_owner: "CN=node-b,O=reddb".into(),
1060 previous_epoch: 1,
1061 new_epoch: 2,
1062 commit_watermark: 12,
1063 target_lsn: 12,
1064 }
1065 .emit(&logger);
1066 drain(&logger);
1067 let v = read_last_line(&path);
1068 assert_eq!(
1069 v.get("action").and_then(|x| x.as_str()),
1070 Some("operator/cooperative_lease_handoff")
1071 );
1072 let detail = v.get("detail").expect("event detail");
1073 assert_eq!(
1074 detail.get("range_identity").and_then(|x| x.as_str()),
1075 Some("system.global/0")
1076 );
1077 assert_eq!(
1078 detail.get("previous_owner").and_then(|x| x.as_str()),
1079 Some("CN=node-a,O=reddb")
1080 );
1081 assert_eq!(
1082 detail.get("new_owner").and_then(|x| x.as_str()),
1083 Some("CN=node-b,O=reddb")
1084 );
1085 assert_eq!(
1086 detail.get("previous_epoch").and_then(|x| x.as_i64()),
1087 Some(1)
1088 );
1089 assert_eq!(detail.get("new_epoch").and_then(|x| x.as_i64()), Some(2));
1090 assert_eq!(
1091 detail.get("commit_watermark").and_then(|x| x.as_i64()),
1092 Some(12)
1093 );
1094 assert_eq!(detail.get("target_lsn").and_then(|x| x.as_i64()), Some(12));
1095 }
1096
1097 #[test]
1098 fn replica_authority_divergence_emits_range_and_term_epoch_pairs() {
1099 let (logger, path) = make_logger();
1100 OperatorEvent::ReplicaAuthorityDivergence {
1101 range_identity: "system.global/0".into(),
1102 expected_term: 8,
1103 expected_ownership_epoch: 2,
1104 observed_term: 7,
1105 observed_ownership_epoch: 1,
1106 lsn: 42,
1107 }
1108 .emit(&logger);
1109 drain(&logger);
1110 let v = read_last_line(&path);
1111 assert_eq!(
1112 v.get("action").and_then(|x| x.as_str()),
1113 Some("operator/replica_authority_divergence")
1114 );
1115 let detail = v.get("detail").expect("event detail");
1116 assert_eq!(
1117 detail.get("range_identity").and_then(|x| x.as_str()),
1118 Some("system.global/0")
1119 );
1120 assert_eq!(
1121 detail.get("expected_term").and_then(|x| x.as_i64()),
1122 Some(8)
1123 );
1124 assert_eq!(
1125 detail
1126 .get("expected_ownership_epoch")
1127 .and_then(|x| x.as_i64()),
1128 Some(2)
1129 );
1130 assert_eq!(
1131 detail.get("observed_term").and_then(|x| x.as_i64()),
1132 Some(7)
1133 );
1134 assert_eq!(
1135 detail
1136 .get("observed_ownership_epoch")
1137 .and_then(|x| x.as_i64()),
1138 Some(1)
1139 );
1140 assert_eq!(detail.get("lsn").and_then(|x| x.as_i64()), Some(42));
1141 }
1142
1143 #[test]
1148 fn adversarial_fields_are_escape_safe() {
1149 let payloads: &[(&str, &str)] = &[
1150 ("crlf", "line1\r\nline2"),
1151 ("nul", "before\0after"),
1152 ("quote", r#"she said "hi""#),
1153 ("json_inject", r#"{"injected":true}"#),
1154 ("low_ctrl", "\x01\x02\x07\x1f"),
1155 ("backslash", "C:\\path\\file"),
1156 ("mixed", "name=\"x\"\n\\path\t\x01end"),
1157 ];
1158
1159 for (label, payload) in payloads {
1160 let (logger, path) = make_logger();
1161 OperatorEvent::SchemaCorruption {
1162 collection: payload.to_string(),
1163 detail: payload.to_string(),
1164 }
1165 .emit(&logger);
1166 drain(&logger);
1167
1168 let body = std::fs::read_to_string(&path).unwrap();
1169 let line = body.lines().last().unwrap_or("");
1170
1171 assert!(
1173 !line.contains('\n'),
1174 "{label}: embedded newline in JSONL row"
1175 );
1176
1177 let v: crate::json::Value = crate::json::from_str(line)
1178 .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
1179 let recovered = v
1180 .get("detail")
1181 .and_then(|d| d.get("collection"))
1182 .and_then(|x| x.as_str())
1183 .unwrap_or("");
1184 assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
1185 }
1186 }
1187
1188 #[test]
1193 fn emit_sets_outcome_error_and_source_system() {
1194 let (logger, path) = make_logger();
1195 OperatorEvent::ShutdownForced {
1196 reason: "test".into(),
1197 }
1198 .emit(&logger);
1199 drain(&logger);
1200 let v = read_last_line(&path);
1201 assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
1202 assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
1203 }
1204}