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 StartupFailed { phase: String, error: String },
110 ShutdownForced { reason: String },
113 SchemaCorruption { collection: String, detail: String },
115 CheckpointFailed { lsn: u64, error: String },
117 DanglingAdminIntent {
121 id: crate::crypto::uuid::Uuid,
122 op: crate::telemetry::admin_intent_log::IntentOp,
123 started_at_ms: u64,
125 last_phase: crate::telemetry::admin_intent_log::IntentPhase,
126 },
127 ConfigChangeRequiresRestart { fields_changed: String },
131 SubscriptionSchemaChange {
135 collection: String,
136 subscription_names: String,
138 fields_added: String,
140 fields_removed: String,
142 lsn: u64,
143 },
144 OutboxDlqActivated {
147 queue: String,
148 dlq: String,
149 reason: String,
150 },
151 QueueDlqPromoted {
155 queue: String,
156 group: String,
157 dlq: String,
158 message_id: u64,
159 attempts: u32,
160 reason: String,
161 },
162 DeposedPrimaryRollback {
173 common_point_lsn: u64,
176 tail_to_lsn: u64,
178 tail_lsns: u64,
180 commit_watermark: u64,
184 rollback_file: String,
186 new_primary_addr: String,
188 new_term: u64,
190 },
191 OwnershipFenced {
194 reason: String,
195 ownership_epoch: u64,
196 range_identity: String,
197 },
198 ReplicaAuthorityDivergence {
201 range_identity: String,
202 expected_term: u64,
203 expected_ownership_epoch: u64,
204 observed_term: u64,
205 observed_ownership_epoch: u64,
206 lsn: u64,
207 },
208}
209
210impl OperatorEvent {
211 pub fn all_variant_names() -> &'static [&'static str] {
213 &[
214 "ReplicationBroken",
215 "Divergence",
216 "WalFsyncFailed",
217 "DiskSpaceCritical",
218 "AuthBypass",
219 "AdminCapabilityGranted",
220 "SecretRotationFailed",
221 "ConfigChanged",
222 "StartupFailed",
223 "ShutdownForced",
224 "SchemaCorruption",
225 "CheckpointFailed",
226 "DanglingAdminIntent",
227 "ConfigChangeRequiresRestart",
228 "SubscriptionSchemaChange",
229 "OutboxDlqActivated",
230 "QueueDlqPromoted",
231 "DeposedPrimaryRollback",
232 "OwnershipFenced",
233 "ReplicaAuthorityDivergence",
234 ]
235 }
236
237 pub(super) fn variant_name(&self) -> &'static str {
239 match self {
240 Self::ReplicationBroken { .. } => "ReplicationBroken",
241 Self::Divergence { .. } => "Divergence",
242 Self::WalFsyncFailed { .. } => "WalFsyncFailed",
243 Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
244 Self::AuthBypass { .. } => "AuthBypass",
245 Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
246 Self::SecretRotationFailed { .. } => "SecretRotationFailed",
247 Self::ConfigChanged { .. } => "ConfigChanged",
248 Self::StartupFailed { .. } => "StartupFailed",
249 Self::ShutdownForced { .. } => "ShutdownForced",
250 Self::SchemaCorruption { .. } => "SchemaCorruption",
251 Self::CheckpointFailed { .. } => "CheckpointFailed",
252 Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
253 Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
254 Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
255 Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
256 Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
257 Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
258 Self::OwnershipFenced { .. } => "OwnershipFenced",
259 Self::ReplicaAuthorityDivergence { .. } => "ReplicaAuthorityDivergence",
260 }
261 }
262
263 pub fn emit_global(self) {
277 match GLOBAL_SINK.get() {
278 Some(logger) => self.emit(logger.as_ref()),
279 None => {
280 let (_, _, summary) = self.decompose();
281 tracing::warn!(target: "reddb::operator", "{summary}");
282 eprintln!("[reddb::operator] (no audit sink) {summary}");
283 }
284 }
285 }
286
287 pub fn emit(self, audit: &AuditLogger) {
288 let (action, fields, summary) = self.decompose();
289
290 let ev = AuditEvent::builder(action)
291 .source(AuditAuthSource::System)
292 .outcome(Outcome::Error)
293 .fields(fields)
294 .build();
295
296 let audit_ok = {
298 audit.record_event(ev);
302 true
303 };
304
305 tracing::warn!(target: "reddb::operator", "{summary}");
307
308 if !audit_ok {
311 eprintln!("[reddb::operator] {summary}");
312 }
313 }
314
315 pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
317 match self {
318 Self::ReplicationBroken { peer, reason } => {
319 let summary = format!("replication broken: peer={peer} reason={reason}");
320 let fields = vec![
321 AuditFieldEscaper::field("peer", peer),
322 AuditFieldEscaper::field("reason", reason),
323 ];
324 ("operator/replication_broken", fields, summary)
325 }
326 Self::Divergence {
327 peer,
328 leader_lsn,
329 follower_lsn,
330 } => {
331 let summary = format!(
332 "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
333 );
334 let fields = vec![
335 AuditFieldEscaper::field("peer", peer),
336 AuditFieldEscaper::field("leader_lsn", leader_lsn),
337 AuditFieldEscaper::field("follower_lsn", follower_lsn),
338 ];
339 ("operator/divergence", fields, summary)
340 }
341 Self::WalFsyncFailed { path, error } => {
342 let summary = format!("WAL fsync failed: path={path} error={error}");
343 let fields = vec![
344 AuditFieldEscaper::field("path", path),
345 AuditFieldEscaper::field("error", error),
346 ];
347 ("operator/wal_fsync_failed", fields, summary)
348 }
349 Self::DiskSpaceCritical {
350 path,
351 available_bytes,
352 threshold_bytes,
353 } => {
354 let summary = format!(
355 "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
356 );
357 let fields = vec![
358 AuditFieldEscaper::field("path", path),
359 AuditFieldEscaper::field("available_bytes", available_bytes),
360 AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
361 ];
362 ("operator/disk_space_critical", fields, summary)
363 }
364 Self::AuthBypass {
365 principal,
366 resource,
367 detail,
368 } => {
369 let summary =
370 format!("auth bypass detected: principal={principal} resource={resource}");
371 let fields = vec![
372 AuditFieldEscaper::field("principal", principal),
373 AuditFieldEscaper::field("resource", resource),
374 AuditFieldEscaper::field("detail", detail),
375 ];
376 ("operator/auth_bypass", fields, summary)
377 }
378 Self::AdminCapabilityGranted {
379 granted_to,
380 capability,
381 granted_by,
382 } => {
383 let summary = format!(
384 "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
385 );
386 let fields = vec![
387 AuditFieldEscaper::field("granted_to", granted_to),
388 AuditFieldEscaper::field("capability", capability),
389 AuditFieldEscaper::field("granted_by", granted_by),
390 ];
391 ("operator/admin_capability_granted", fields, summary)
392 }
393 Self::SecretRotationFailed { secret_ref, error } => {
394 let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
395 let fields = vec![
396 AuditFieldEscaper::field("secret_ref", secret_ref),
397 AuditFieldEscaper::field("error", error),
398 ];
399 ("operator/secret_rotation_failed", fields, summary)
400 }
401 Self::ConfigChanged {
402 key,
403 old_value,
404 new_value,
405 changed_by,
406 } => {
407 let summary = format!(
408 "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
409 );
410 let fields = vec![
411 AuditFieldEscaper::field("key", key),
412 AuditFieldEscaper::field("old_value", old_value),
413 AuditFieldEscaper::field("new_value", new_value),
414 AuditFieldEscaper::field("changed_by", changed_by),
415 ];
416 ("operator/config_changed", fields, summary)
417 }
418 Self::StartupFailed { phase, error } => {
419 let summary = format!("startup failed: phase={phase} error={error}");
420 let fields = vec![
421 AuditFieldEscaper::field("phase", phase),
422 AuditFieldEscaper::field("error", error),
423 ];
424 ("operator/startup_failed", fields, summary)
425 }
426 Self::ShutdownForced { reason } => {
427 let summary = format!("shutdown forced: reason={reason}");
428 let fields = vec![AuditFieldEscaper::field("reason", reason)];
429 ("operator/shutdown_forced", fields, summary)
430 }
431 Self::SchemaCorruption { collection, detail } => {
432 let summary = format!("schema corruption: collection={collection} detail={detail}");
433 let fields = vec![
434 AuditFieldEscaper::field("collection", collection),
435 AuditFieldEscaper::field("detail", detail),
436 ];
437 ("operator/schema_corruption", fields, summary)
438 }
439 Self::CheckpointFailed { lsn, error } => {
440 let summary = format!("checkpoint failed: lsn={lsn} error={error}");
441 let fields = vec![
442 AuditFieldEscaper::field("lsn", lsn),
443 AuditFieldEscaper::field("error", error),
444 ];
445 ("operator/checkpoint_failed", fields, summary)
446 }
447 Self::DanglingAdminIntent {
448 id,
449 op,
450 started_at_ms,
451 last_phase,
452 } => {
453 let summary = format!(
454 "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
455 );
456 let fields = vec![
457 AuditFieldEscaper::field("id", id.to_string()),
458 AuditFieldEscaper::field("op", op.to_string()),
459 AuditFieldEscaper::field("started_at_ms", started_at_ms),
460 AuditFieldEscaper::field("last_phase", last_phase.to_string()),
461 ];
462 ("operator/dangling_admin_intent", fields, summary)
463 }
464 Self::ConfigChangeRequiresRestart { fields_changed } => {
465 let summary = format!("config change requires restart: fields={fields_changed}");
466 let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
467 ("operator/config_change_requires_restart", fields, summary)
468 }
469 Self::SubscriptionSchemaChange {
470 collection,
471 subscription_names,
472 fields_added,
473 fields_removed,
474 lsn,
475 } => {
476 let summary = format!(
477 "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
478 );
479 let fields = vec![
480 AuditFieldEscaper::field("collection", collection),
481 AuditFieldEscaper::field("subscription_names", subscription_names),
482 AuditFieldEscaper::field("fields_added", fields_added),
483 AuditFieldEscaper::field("fields_removed", fields_removed),
484 AuditFieldEscaper::field("lsn", lsn),
485 ];
486 ("operator/subscription_schema_change", fields, summary)
487 }
488 Self::OutboxDlqActivated { queue, dlq, reason } => {
489 let summary =
490 format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
491 let fields = vec![
492 AuditFieldEscaper::field("queue", queue),
493 AuditFieldEscaper::field("dlq", dlq),
494 AuditFieldEscaper::field("reason", reason),
495 ];
496 ("operator/outbox_dlq_activated", fields, summary)
497 }
498 Self::QueueDlqPromoted {
499 queue,
500 group,
501 dlq,
502 message_id,
503 attempts,
504 reason,
505 } => {
506 let summary = format!(
507 "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
508 );
509 let fields = vec![
510 AuditFieldEscaper::field("queue", queue),
511 AuditFieldEscaper::field("group", group),
512 AuditFieldEscaper::field("dlq", dlq),
513 AuditFieldEscaper::field("message_id", message_id),
514 AuditFieldEscaper::field("attempts", attempts as u64),
515 AuditFieldEscaper::field("reason", reason),
516 ];
517 ("operator/queue_dlq_promoted", fields, summary)
518 }
519 Self::DeposedPrimaryRollback {
520 common_point_lsn,
521 tail_to_lsn,
522 tail_lsns,
523 commit_watermark,
524 rollback_file,
525 new_primary_addr,
526 new_term,
527 } => {
528 let summary = format!(
529 "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
530 (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
531 up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
532 {new_primary_addr} under term {new_term}"
533 );
534 let fields = vec![
535 AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
536 AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
537 AuditFieldEscaper::field("tail_lsns", tail_lsns),
538 AuditFieldEscaper::field("commit_watermark", commit_watermark),
539 AuditFieldEscaper::field("rollback_file", rollback_file),
540 AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
541 AuditFieldEscaper::field("new_term", new_term),
542 ];
543 ("operator/deposed_primary_rollback", fields, summary)
544 }
545 Self::OwnershipFenced {
546 reason,
547 ownership_epoch,
548 range_identity,
549 } => {
550 let summary = format!(
551 "ownership fenced: reason={reason} ownership_epoch={ownership_epoch} range={range_identity}"
552 );
553 let fields = vec![
554 AuditFieldEscaper::field("reason", reason),
555 AuditFieldEscaper::field("ownership_epoch", ownership_epoch),
556 AuditFieldEscaper::field("range_identity", range_identity),
557 ];
558 ("operator/ownership_fenced", fields, summary)
559 }
560 Self::ReplicaAuthorityDivergence {
561 range_identity,
562 expected_term,
563 expected_ownership_epoch,
564 observed_term,
565 observed_ownership_epoch,
566 lsn,
567 } => {
568 let summary = format!(
569 "replica authority divergence: range={range_identity} lsn={lsn} \
570 expected=({expected_term},{expected_ownership_epoch}) \
571 observed=({observed_term},{observed_ownership_epoch})"
572 );
573 let fields = vec![
574 AuditFieldEscaper::field("range_identity", range_identity),
575 AuditFieldEscaper::field("expected_term", expected_term),
576 AuditFieldEscaper::field("expected_ownership_epoch", expected_ownership_epoch),
577 AuditFieldEscaper::field("observed_term", observed_term),
578 AuditFieldEscaper::field("observed_ownership_epoch", observed_ownership_epoch),
579 AuditFieldEscaper::field("lsn", lsn),
580 ];
581 ("operator/replica_authority_divergence", fields, summary)
582 }
583 }
584 }
585}
586
587#[cfg(test)]
592mod tests {
593 use std::time::Duration;
594
595 use super::*;
596 use crate::runtime::audit_log::AuditLogger;
597
598 struct TempAuditPath(std::path::PathBuf);
599
600 impl std::ops::Deref for TempAuditPath {
601 type Target = std::path::PathBuf;
602
603 fn deref(&self) -> &std::path::PathBuf {
604 &self.0
605 }
606 }
607
608 impl AsRef<std::path::Path> for TempAuditPath {
609 fn as_ref(&self) -> &std::path::Path {
610 &self.0
611 }
612 }
613
614 impl Drop for TempAuditPath {
615 fn drop(&mut self) {
616 if let Some(parent) = self.0.parent() {
617 let _ = std::fs::remove_dir_all(parent);
618 }
619 }
620 }
621
622 fn make_logger() -> (AuditLogger, TempAuditPath) {
623 let dir = std::env::temp_dir().join(format!(
624 "reddb-op-event-{}-{}",
625 std::process::id(),
626 crate::utils::now_unix_nanos()
627 ));
628 std::fs::create_dir_all(&dir).unwrap();
629 let path = dir.join(".audit.log");
630 let logger = AuditLogger::with_path(path.clone());
631 (logger, TempAuditPath(path))
632 }
633
634 fn drain(logger: &AuditLogger) {
635 assert!(
636 logger.wait_idle(Duration::from_secs(2)),
637 "audit logger drain timed out"
638 );
639 }
640
641 fn read_last_line(path: &std::path::Path) -> crate::json::Value {
642 let body = std::fs::read_to_string(path).unwrap();
643 let line = body.lines().last().expect("at least one audit line");
644 crate::json::from_str(line).expect("valid JSON")
645 }
646
647 #[test]
652 fn replication_broken_emits() {
653 let (logger, path) = make_logger();
654 OperatorEvent::ReplicationBroken {
655 peer: "replica-1".into(),
656 reason: "TCP reset".into(),
657 }
658 .emit(&logger);
659 drain(&logger);
660 let v = read_last_line(&path);
661 assert_eq!(
662 v.get("action").and_then(|x| x.as_str()),
663 Some("operator/replication_broken")
664 );
665 let peer = v
666 .get("detail")
667 .and_then(|d| d.get("peer"))
668 .and_then(|x| x.as_str());
669 assert_eq!(peer, Some("replica-1"));
670 }
671
672 #[test]
673 fn divergence_emits() {
674 let (logger, path) = make_logger();
675 OperatorEvent::Divergence {
676 peer: "replica-2".into(),
677 leader_lsn: 1000,
678 follower_lsn: 999,
679 }
680 .emit(&logger);
681 drain(&logger);
682 let v = read_last_line(&path);
683 assert_eq!(
684 v.get("action").and_then(|x| x.as_str()),
685 Some("operator/divergence")
686 );
687 let lsn = v
688 .get("detail")
689 .and_then(|d| d.get("leader_lsn"))
690 .and_then(|x| x.as_i64());
691 assert_eq!(lsn, Some(1000));
692 }
693
694 #[test]
695 fn wal_fsync_failed_emits() {
696 let (logger, path) = make_logger();
697 OperatorEvent::WalFsyncFailed {
698 path: "/data/wal".into(),
699 error: "EIO".into(),
700 }
701 .emit(&logger);
702 drain(&logger);
703 let v = read_last_line(&path);
704 assert_eq!(
705 v.get("action").and_then(|x| x.as_str()),
706 Some("operator/wal_fsync_failed")
707 );
708 let err = v
709 .get("detail")
710 .and_then(|d| d.get("error"))
711 .and_then(|x| x.as_str());
712 assert_eq!(err, Some("EIO"));
713 }
714
715 #[test]
716 fn disk_space_critical_emits() {
717 let (logger, path) = make_logger();
718 OperatorEvent::DiskSpaceCritical {
719 path: "/data".into(),
720 available_bytes: 1024,
721 threshold_bytes: 10240,
722 }
723 .emit(&logger);
724 drain(&logger);
725 let v = read_last_line(&path);
726 assert_eq!(
727 v.get("action").and_then(|x| x.as_str()),
728 Some("operator/disk_space_critical")
729 );
730 let avail = v
731 .get("detail")
732 .and_then(|d| d.get("available_bytes"))
733 .and_then(|x| x.as_i64());
734 assert_eq!(avail, Some(1024));
735 }
736
737 #[test]
738 fn auth_bypass_emits() {
739 let (logger, path) = make_logger();
740 OperatorEvent::AuthBypass {
741 principal: "alice".into(),
742 resource: "/admin/drop".into(),
743 detail: "scope check skipped".into(),
744 }
745 .emit(&logger);
746 drain(&logger);
747 let v = read_last_line(&path);
748 assert_eq!(
749 v.get("action").and_then(|x| x.as_str()),
750 Some("operator/auth_bypass")
751 );
752 let res = v
753 .get("detail")
754 .and_then(|d| d.get("resource"))
755 .and_then(|x| x.as_str());
756 assert_eq!(res, Some("/admin/drop"));
757 }
758
759 #[test]
760 fn admin_capability_granted_emits() {
761 let (logger, path) = make_logger();
762 OperatorEvent::AdminCapabilityGranted {
763 granted_to: "bob".into(),
764 capability: "ADMIN_WRITE".into(),
765 granted_by: "root".into(),
766 }
767 .emit(&logger);
768 drain(&logger);
769 let v = read_last_line(&path);
770 assert_eq!(
771 v.get("action").and_then(|x| x.as_str()),
772 Some("operator/admin_capability_granted")
773 );
774 let cap = v
775 .get("detail")
776 .and_then(|d| d.get("capability"))
777 .and_then(|x| x.as_str());
778 assert_eq!(cap, Some("ADMIN_WRITE"));
779 }
780
781 #[test]
782 fn secret_rotation_failed_emits() {
783 let (logger, path) = make_logger();
784 OperatorEvent::SecretRotationFailed {
785 secret_ref: "jwt-signing-key".into(),
786 error: "HSM unreachable".into(),
787 }
788 .emit(&logger);
789 drain(&logger);
790 let v = read_last_line(&path);
791 assert_eq!(
792 v.get("action").and_then(|x| x.as_str()),
793 Some("operator/secret_rotation_failed")
794 );
795 let r = v
796 .get("detail")
797 .and_then(|d| d.get("secret_ref"))
798 .and_then(|x| x.as_str());
799 assert_eq!(r, Some("jwt-signing-key"));
800 }
801
802 #[test]
803 fn config_changed_emits() {
804 let (logger, path) = make_logger();
805 OperatorEvent::ConfigChanged {
806 key: "max_connections".into(),
807 old_value: "100".into(),
808 new_value: "200".into(),
809 changed_by: "ops-bot".into(),
810 }
811 .emit(&logger);
812 drain(&logger);
813 let v = read_last_line(&path);
814 assert_eq!(
815 v.get("action").and_then(|x| x.as_str()),
816 Some("operator/config_changed")
817 );
818 let nv = v
819 .get("detail")
820 .and_then(|d| d.get("new_value"))
821 .and_then(|x| x.as_str());
822 assert_eq!(nv, Some("200"));
823 }
824
825 #[test]
826 fn startup_failed_emits() {
827 let (logger, path) = make_logger();
828 OperatorEvent::StartupFailed {
829 phase: "wal_recovery".into(),
830 error: "corrupt frame".into(),
831 }
832 .emit(&logger);
833 drain(&logger);
834 let v = read_last_line(&path);
835 assert_eq!(
836 v.get("action").and_then(|x| x.as_str()),
837 Some("operator/startup_failed")
838 );
839 let phase = v
840 .get("detail")
841 .and_then(|d| d.get("phase"))
842 .and_then(|x| x.as_str());
843 assert_eq!(phase, Some("wal_recovery"));
844 }
845
846 #[test]
847 fn shutdown_forced_emits() {
848 let (logger, path) = make_logger();
849 OperatorEvent::ShutdownForced {
850 reason: "OOM".into(),
851 }
852 .emit(&logger);
853 drain(&logger);
854 let v = read_last_line(&path);
855 assert_eq!(
856 v.get("action").and_then(|x| x.as_str()),
857 Some("operator/shutdown_forced")
858 );
859 let r = v
860 .get("detail")
861 .and_then(|d| d.get("reason"))
862 .and_then(|x| x.as_str());
863 assert_eq!(r, Some("OOM"));
864 }
865
866 #[test]
867 fn schema_corruption_emits() {
868 let (logger, path) = make_logger();
869 OperatorEvent::SchemaCorruption {
870 collection: "users".into(),
871 detail: "unknown type tag 0xFF".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/schema_corruption")
879 );
880 let coll = v
881 .get("detail")
882 .and_then(|d| d.get("collection"))
883 .and_then(|x| x.as_str());
884 assert_eq!(coll, Some("users"));
885 }
886
887 #[test]
888 fn checkpoint_failed_emits() {
889 let (logger, path) = make_logger();
890 OperatorEvent::CheckpointFailed {
891 lsn: 42_000,
892 error: "write stall".into(),
893 }
894 .emit(&logger);
895 drain(&logger);
896 let v = read_last_line(&path);
897 assert_eq!(
898 v.get("action").and_then(|x| x.as_str()),
899 Some("operator/checkpoint_failed")
900 );
901 let lsn = v
902 .get("detail")
903 .and_then(|d| d.get("lsn"))
904 .and_then(|x| x.as_i64());
905 assert_eq!(lsn, Some(42_000));
906 }
907
908 #[test]
909 fn deposed_primary_rollback_emits() {
910 let (logger, path) = make_logger();
911 OperatorEvent::DeposedPrimaryRollback {
912 common_point_lsn: 200,
913 tail_to_lsn: 230,
914 tail_lsns: 30,
915 commit_watermark: 200,
916 rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
917 new_primary_addr: "http://node-b:55055".into(),
918 new_term: 8,
919 }
920 .emit(&logger);
921 drain(&logger);
922 let v = read_last_line(&path);
923 assert_eq!(
924 v.get("action").and_then(|x| x.as_str()),
925 Some("operator/deposed_primary_rollback")
926 );
927 let cp = v
928 .get("detail")
929 .and_then(|d| d.get("common_point_lsn"))
930 .and_then(|x| x.as_i64());
931 assert_eq!(cp, Some(200));
932 let file = v
933 .get("detail")
934 .and_then(|d| d.get("rollback_file"))
935 .and_then(|x| x.as_str());
936 assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
937 }
938
939 #[test]
940 fn ownership_fenced_emits_reason_epoch_and_range() {
941 let (logger, path) = make_logger();
942 OperatorEvent::OwnershipFenced {
943 reason: "stale_ownership".into(),
944 ownership_epoch: 2,
945 range_identity: "system.global/0".into(),
946 }
947 .emit(&logger);
948 drain(&logger);
949 let v = read_last_line(&path);
950 assert_eq!(
951 v.get("action").and_then(|x| x.as_str()),
952 Some("operator/ownership_fenced")
953 );
954 let detail = v.get("detail").expect("event detail");
955 assert_eq!(
956 detail.get("reason").and_then(|x| x.as_str()),
957 Some("stale_ownership")
958 );
959 assert_eq!(
960 detail.get("ownership_epoch").and_then(|x| x.as_i64()),
961 Some(2)
962 );
963 assert_eq!(
964 detail.get("range_identity").and_then(|x| x.as_str()),
965 Some("system.global/0")
966 );
967 }
968
969 #[test]
970 fn replica_authority_divergence_emits_range_and_term_epoch_pairs() {
971 let (logger, path) = make_logger();
972 OperatorEvent::ReplicaAuthorityDivergence {
973 range_identity: "system.global/0".into(),
974 expected_term: 8,
975 expected_ownership_epoch: 2,
976 observed_term: 7,
977 observed_ownership_epoch: 1,
978 lsn: 42,
979 }
980 .emit(&logger);
981 drain(&logger);
982 let v = read_last_line(&path);
983 assert_eq!(
984 v.get("action").and_then(|x| x.as_str()),
985 Some("operator/replica_authority_divergence")
986 );
987 let detail = v.get("detail").expect("event detail");
988 assert_eq!(
989 detail.get("range_identity").and_then(|x| x.as_str()),
990 Some("system.global/0")
991 );
992 assert_eq!(
993 detail.get("expected_term").and_then(|x| x.as_i64()),
994 Some(8)
995 );
996 assert_eq!(
997 detail
998 .get("expected_ownership_epoch")
999 .and_then(|x| x.as_i64()),
1000 Some(2)
1001 );
1002 assert_eq!(
1003 detail.get("observed_term").and_then(|x| x.as_i64()),
1004 Some(7)
1005 );
1006 assert_eq!(
1007 detail
1008 .get("observed_ownership_epoch")
1009 .and_then(|x| x.as_i64()),
1010 Some(1)
1011 );
1012 assert_eq!(detail.get("lsn").and_then(|x| x.as_i64()), Some(42));
1013 }
1014
1015 #[test]
1020 fn adversarial_fields_are_escape_safe() {
1021 let payloads: &[(&str, &str)] = &[
1022 ("crlf", "line1\r\nline2"),
1023 ("nul", "before\0after"),
1024 ("quote", r#"she said "hi""#),
1025 ("json_inject", r#"{"injected":true}"#),
1026 ("low_ctrl", "\x01\x02\x07\x1f"),
1027 ("backslash", "C:\\path\\file"),
1028 ("mixed", "name=\"x\"\n\\path\t\x01end"),
1029 ];
1030
1031 for (label, payload) in payloads {
1032 let (logger, path) = make_logger();
1033 OperatorEvent::SchemaCorruption {
1034 collection: payload.to_string(),
1035 detail: payload.to_string(),
1036 }
1037 .emit(&logger);
1038 drain(&logger);
1039
1040 let body = std::fs::read_to_string(&path).unwrap();
1041 let line = body.lines().last().unwrap_or("");
1042
1043 assert!(
1045 !line.contains('\n'),
1046 "{label}: embedded newline in JSONL row"
1047 );
1048
1049 let v: crate::json::Value = crate::json::from_str(line)
1050 .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
1051 let recovered = v
1052 .get("detail")
1053 .and_then(|d| d.get("collection"))
1054 .and_then(|x| x.as_str())
1055 .unwrap_or("");
1056 assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
1057 }
1058 }
1059
1060 #[test]
1065 fn emit_sets_outcome_error_and_source_system() {
1066 let (logger, path) = make_logger();
1067 OperatorEvent::ShutdownForced {
1068 reason: "test".into(),
1069 }
1070 .emit(&logger);
1071 drain(&logger);
1072 let v = read_last_line(&path);
1073 assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
1074 assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
1075 }
1076}