[PATCH 15/15] selftests: add Ceph BLOG smoke test and collection helpers

From: Alex Markuze

Date: Wed Jun 17 2026 - 11:04:15 EST


Add kselftests: blog_smoke_test.sh (dual-mount isolation, enable/
disable), blog_collect.sh, blog_analyze.sh, and kselftest wiring.

Add F: pattern for tools/testing/selftests/ceph_blog/ to the CephFS
MAINTAINERS entry.

Signed-off-by: Alex Markuze <amarkuze@xxxxxxxxxx>
---
MAINTAINERS | 1 +
tools/testing/selftests/Makefile | 1 +
tools/testing/selftests/ceph_blog/Makefile | 7 +
.../selftests/ceph_blog/blog_analyze.sh | 124 ++++++
.../selftests/ceph_blog/blog_collect.sh | 116 ++++++
.../selftests/ceph_blog/blog_smoke_test.sh | 363 ++++++++++++++++++
tools/testing/selftests/ceph_blog/config | 2 +
7 files changed, 614 insertions(+)
create mode 100644 tools/testing/selftests/ceph_blog/Makefile
create mode 100644 tools/testing/selftests/ceph_blog/blog_analyze.sh
create mode 100644 tools/testing/selftests/ceph_blog/blog_collect.sh
create mode 100644 tools/testing/selftests/ceph_blog/blog_smoke_test.sh
create mode 100644 tools/testing/selftests/ceph_blog/config

diff --git a/MAINTAINERS b/MAINTAINERS
index 441b556b9671..9a7265ce7de5 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -5909,6 +5909,7 @@ B: https://tracker.ceph.com/
T: git https://github.com/ceph/ceph-client.git
F: Documentation/filesystems/ceph.rst
F: fs/ceph/
+F: tools/testing/selftests/ceph_blog/
F: tools/testing/selftests/filesystems/ceph/

CERTIFICATE HANDLING
diff --git a/tools/testing/selftests/Makefile b/tools/testing/selftests/Makefile
index ab254ae793a9..d7e0138fcf3a 100644
--- a/tools/testing/selftests/Makefile
+++ b/tools/testing/selftests/Makefile
@@ -7,6 +7,7 @@ TARGETS += bpf
TARGETS += breakpoints
TARGETS += cachestat
TARGETS += capabilities
+TARGETS += ceph_blog
TARGETS += cgroup
TARGETS += clone3
TARGETS += connector
diff --git a/tools/testing/selftests/ceph_blog/Makefile b/tools/testing/selftests/ceph_blog/Makefile
new file mode 100644
index 000000000000..39f6d0c8990c
--- /dev/null
+++ b/tools/testing/selftests/ceph_blog/Makefile
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: GPL-2.0
+
+TEST_PROGS := blog_smoke_test.sh
+TEST_PROGS += blog_collect.sh
+TEST_PROGS += blog_analyze.sh
+
+include ../lib.mk
diff --git a/tools/testing/selftests/ceph_blog/blog_analyze.sh b/tools/testing/selftests/ceph_blog/blog_analyze.sh
new file mode 100644
index 000000000000..1f0bf049fbb9
--- /dev/null
+++ b/tools/testing/selftests/ceph_blog/blog_analyze.sh
@@ -0,0 +1,124 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+#
+# blog_analyze.sh - Analyze BLOG trace snapshots
+#
+# Parses the output of blog_collect.sh or blog_smoke_test.sh and
+# produces a summary: entry counts, source-file breakdown, timeline,
+# and per-client statistics.
+#
+# Usage:
+# ./blog_analyze.sh <snapshot_dir>
+# ./blog_analyze.sh /tmp/blog_snapshot_20260413_120000
+#
+set -euo pipefail
+
+DIR="${1:?Usage: $0 <snapshot_dir>}"
+[[ -d "$DIR" ]] || { echo "Not a directory: $DIR"; exit 1; }
+
+ENTRIES="$DIR/entries.txt"
+SOURCES="$DIR/sources.txt"
+CLIENTS="$DIR/clients.txt"
+STATS="$DIR/stats.txt"
+
+echo "=========================================="
+echo " BLOG Trace Analysis"
+echo " Source: $DIR"
+echo "=========================================="
+echo ""
+
+# ---------- entry summary ----------
+if [[ -f "$ENTRIES" ]]; then
+ TOTAL=$(wc -l < "$ENTRIES")
+ echo "--- Entries ---"
+ echo " Total lines: $TOTAL"
+
+ if (( TOTAL > 0 )); then
+ FIRST=$(head -1 "$ENTRIES" | awk '{print $1, $2}')
+ LAST=$(tail -1 "$ENTRIES" | awk '{print $1, $2}')
+ echo " First entry: $FIRST"
+ echo " Last entry: $LAST"
+
+ echo ""
+ echo " Top 10 source locations:"
+ # entries format: "YYYY-MM-DD HH:MM:SS.mmm [file:func:line] message"
+ grep -oE '\[[^]]+\]' "$ENTRIES" 2>/dev/null | sed 's/^\[//;s/\]$//' | \
+ sort | uniq -c | sort -rn | head -10 | \
+ while read -r count loc; do
+ printf " %6d %s\n" "$count" "$loc"
+ done
+
+ echo ""
+ echo " Top 10 most frequent messages (first 60 chars):"
+ # strip timestamp and source prefix, take first 60 chars
+ sed 's/^[0-9-]* [0-9:.]* \[[^]]*\] //' "$ENTRIES" | \
+ cut -c1-60 | sort | uniq -c | sort -rn | head -10 | \
+ while read -r count msg; do
+ printf " %6d %s\n" "$count" "$msg"
+ done
+
+ echo ""
+ echo " Per-second entry rate:"
+ # extract HH:MM:SS, count per second
+ awk '{print $1" "$2}' "$ENTRIES" | \
+ sed 's/\.[0-9]*//' | sort | uniq -c | \
+ awk '{sum+=$1; count++} END {
+ if(count>0) printf " avg %.1f entries/sec over %d seconds\n", sum/count, count
+ }'
+ fi
+else
+ echo " (no entries.txt found)"
+fi
+
+echo ""
+
+# ---------- source summary ----------
+if [[ -f "$SOURCES" ]]; then
+ echo "--- Registered Sources ---"
+ SOURCE_COUNT=$(grep -c '^ID ' "$SOURCES" 2>/dev/null || echo 0)
+ echo " Total registered: $SOURCE_COUNT"
+
+ if (( SOURCE_COUNT > 0 )); then
+ echo ""
+ echo " By file:"
+ grep '^ID ' "$SOURCES" | \
+ sed 's/^ID [0-9]*: //' | cut -d: -f1 | \
+ sort | uniq -c | sort -rn | \
+ while read -r count file; do
+ printf " %4d %s\n" "$count" "$file"
+ done
+ fi
+else
+ echo " (no sources.txt found)"
+fi
+
+echo ""
+
+# ---------- client summary ----------
+if [[ -f "$CLIENTS" ]]; then
+ echo "--- Registered Clients ---"
+ CLIENT_COUNT=$(grep -c '^Client ID' "$CLIENTS" 2>/dev/null || echo 0)
+ echo " Total registered: $CLIENT_COUNT"
+
+ if (( CLIENT_COUNT > 0 )); then
+ grep -A2 '^Client ID' "$CLIENTS" | \
+ while IFS= read -r line; do
+ echo " $line"
+ done
+ fi
+else
+ echo " (no clients.txt found)"
+fi
+
+echo ""
+
+# ---------- stats summary ----------
+if [[ -f "$STATS" ]]; then
+ echo "--- Logger Statistics ---"
+ sed 's/^/ /' "$STATS"
+else
+ echo " (no stats.txt found)"
+fi
+
+echo ""
+echo "=========================================="
diff --git a/tools/testing/selftests/ceph_blog/blog_collect.sh b/tools/testing/selftests/ceph_blog/blog_collect.sh
new file mode 100644
index 000000000000..9401724bf43d
--- /dev/null
+++ b/tools/testing/selftests/ceph_blog/blog_collect.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+#
+# blog_collect.sh - Collect BLOG debugfs snapshots from a mounted CephFS
+#
+# Finds the Ceph BLOG debugfs directory, snapshots all files, and
+# optionally tails the entries file for live monitoring.
+#
+# Usage:
+# ./blog_collect.sh [snapshot|tail|clear|stats|sources|clients] [-d blog_dir] [-o dir]
+#
+set -euo pipefail
+
+ACTION="${1:-snapshot}"
+OUTDIR=""
+TARGET_BLOG_DIR="${BLOG_DIR:-}"
+
+shift || true
+while getopts "d:o:" opt; do
+ case $opt in
+ d) TARGET_BLOG_DIR="$OPTARG" ;;
+ o) OUTDIR="$OPTARG" ;;
+ *) ;;
+ esac
+done
+
+DEBUGFS_BASE="/sys/kernel/debug/ceph"
+shopt -s nullglob
+
+find_blog_dir() {
+ local dirs=("$DEBUGFS_BASE"/*/blog)
+
+ if [[ -n "$TARGET_BLOG_DIR" ]]; then
+ [[ -d "$TARGET_BLOG_DIR" ]] || {
+ echo "ERROR: BLOG debugfs directory does not exist: $TARGET_BLOG_DIR" >&2
+ return 1
+ }
+ echo "$TARGET_BLOG_DIR"
+ return 0
+ fi
+
+ if (( ${#dirs[@]} == 0 )); then
+ echo "ERROR: No BLOG debugfs directory found under $DEBUGFS_BASE" >&2
+ echo "Ensure CephFS is mounted and CONFIG_CEPH_FS=y|m" >&2
+ return 1
+ fi
+
+ if (( ${#dirs[@]} > 1 )); then
+ echo "ERROR: Multiple BLOG debugfs directories found:" >&2
+ printf ' %s\n' "${dirs[@]}" >&2
+ echo "Select one with -d <dir> or BLOG_DIR=<dir>." >&2
+ return 1
+ fi
+
+ echo "${dirs[0]}"
+}
+
+BLOG_DIR=$(find_blog_dir)
+echo "BLOG debugfs: $BLOG_DIR"
+
+case "$ACTION" in
+ snapshot)
+ OUTDIR="${OUTDIR:-/tmp/blog_snapshot_$(date +%Y%m%d_%H%M%S)}"
+ mkdir -p "$OUTDIR"
+ for f in enabled entries stats sources clients; do
+ [[ -r "$BLOG_DIR/$f" ]] && cat "$BLOG_DIR/$f" > "$OUTDIR/$f.txt"
+ done
+ echo "Snapshot saved to $OUTDIR/"
+ if [[ -f "$OUTDIR/enabled.txt" ]]; then
+ echo " enabled: $(tr -d '[:space:]' < "$OUTDIR/enabled.txt")"
+ fi
+ echo " entries: $(wc -l < "$OUTDIR/entries.txt") lines"
+ echo " sources: $(grep -c '^ID ' "$OUTDIR/sources.txt" 2>/dev/null || echo 0)"
+ echo " clients: $(grep -c '^FSID:' "$OUTDIR/clients.txt" 2>/dev/null || echo 0)"
+ ;;
+
+ tail)
+ echo "--- Live BLOG entries (Ctrl-C to stop) ---"
+ # Show new entries by polling; entries file is a seq_file so we
+ # re-read the whole thing each interval and diff.
+ PREV=$(mktemp)
+ CURR=""
+ trap 'rm -f "$PREV" "$CURR"' EXIT INT TERM
+ cat "$BLOG_DIR/entries" > "$PREV"
+ while true; do
+ sleep 1
+ CURR=$(mktemp)
+ cat "$BLOG_DIR/entries" > "$CURR"
+ diff "$PREV" "$CURR" | grep '^>' | sed 's/^> //' || true
+ mv "$CURR" "$PREV"
+ CURR=""
+ done
+ ;;
+
+ clear)
+ echo "clear" > "$BLOG_DIR/clear"
+ echo "BLOG entries cleared."
+ ;;
+
+ stats)
+ cat "$BLOG_DIR/stats"
+ ;;
+
+ sources)
+ cat "$BLOG_DIR/sources"
+ ;;
+
+ clients)
+ cat "$BLOG_DIR/clients"
+ ;;
+
+ *)
+ echo "Usage: $0 [snapshot|tail|clear|stats|sources|clients] [-d blog_dir] [-o dir]"
+ exit 1
+ ;;
+esac
diff --git a/tools/testing/selftests/ceph_blog/blog_smoke_test.sh b/tools/testing/selftests/ceph_blog/blog_smoke_test.sh
new file mode 100644
index 000000000000..1623630261c2
--- /dev/null
+++ b/tools/testing/selftests/ceph_blog/blog_smoke_test.sh
@@ -0,0 +1,363 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+#
+# blog_smoke_test.sh - Per-superblock smoke test for Ceph BLOG
+#
+# Verifies that BLOG stays disabled by default, that enabling BLOG on one
+# noshare-mounted CephFS instance only affects that instance, and that a second
+# noshare mount remains quiet until it is enabled separately.
+#
+# Usage:
+# ./blog_smoke_test.sh -m <mon_addr> [-k <secret>] [-n <client_name>]
+# [-p <mount_a>] [-q <mount_b>] [-o <output_dir>]
+#
+# Environment variables:
+# CEPH_MON - monitor address(es)
+# CEPH_SECRET - CephX secret key
+# CEPH_CLIENT - client name, default "admin"
+# CEPH_MOUNT - mount A, default "/mnt/ceph_blog_test_a"
+# CEPH_MOUNT_B - mount B, default "${CEPH_MOUNT}_b"
+# BLOG_OUTPUT - output directory for collected traces
+#
+set -euo pipefail
+shopt -s nullglob
+
+MON="${CEPH_MON:-}"
+SECRET="${CEPH_SECRET:-}"
+CLIENT="${CEPH_CLIENT:-admin}"
+MNT_A="${CEPH_MOUNT:-/mnt/ceph_blog_test_a}"
+MNT_B="${CEPH_MOUNT_B:-}"
+OUTDIR="${BLOG_OUTPUT:-/tmp/blog_test_$(date +%Y%m%d_%H%M%S)}"
+
+TMP_STATE_DIR=""
+DEBUGFS_A=""
+DEBUGFS_B=""
+MOUNTED_A=0
+MOUNTED_B=0
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+NC='\033[0m'
+
+pass() { echo -e " ${GREEN}PASS${NC} $*"; }
+fail() { echo -e " ${RED}FAIL${NC} $*"; }
+info() { echo -e " ${YELLOW}----${NC} $*"; }
+die() { echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; }
+
+usage() {
+ sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# //' >&2
+ exit 1
+}
+
+while getopts "m:k:n:p:q:o:h" opt; do
+ case $opt in
+ m) MON="$OPTARG" ;;
+ k) SECRET="$OPTARG" ;;
+ n) CLIENT="$OPTARG" ;;
+ p) MNT_A="$OPTARG" ;;
+ q) MNT_B="$OPTARG" ;;
+ o) OUTDIR="$OPTARG" ;;
+ h) usage ;;
+ *) usage ;;
+ esac
+done
+
+[[ -z "$MON" ]] && die "Monitor address required (-m or CEPH_MON)"
+[[ $(id -u) -ne 0 ]] && die "Must run as root"
+[[ -z "$MNT_B" ]] && MNT_B="${MNT_A}_b"
+
+cleanup() {
+ info "Cleaning up..."
+ if (( MOUNTED_B )); then
+ umount "$MNT_B" 2>/dev/null || true
+ fi
+ if (( MOUNTED_A )); then
+ umount "$MNT_A" 2>/dev/null || true
+ fi
+ [[ -n "$TMP_STATE_DIR" && -d "$TMP_STATE_DIR" ]] && rm -rf "$TMP_STATE_DIR"
+}
+trap cleanup EXIT
+
+assert_eq() {
+ local got="$1"
+ local want="$2"
+ local msg="$3"
+
+ if [[ "$got" != "$want" ]]; then
+ fail "$msg: expected '$want', got '$got'"
+ exit 1
+ fi
+ pass "$msg: $got"
+}
+
+assert_gt_zero() {
+ local value="$1"
+ local msg="$2"
+
+ if [[ "$value" -le 0 ]]; then
+ fail "$msg: expected > 0, got '$value'"
+ exit 1
+ fi
+ pass "$msg: $value"
+}
+
+have_kconfig() {
+ local pattern="$1"
+
+ grep -qE "$pattern" /boot/config-"$(uname -r)" 2>/dev/null ||
+ zgrep -qE "$pattern" /proc/config.gz 2>/dev/null
+}
+
+capture_blog_dirs() {
+ local dest="$1"
+ local dirs=(/sys/kernel/debug/ceph/*/blog)
+
+ : > "$dest"
+ if (( ${#dirs[@]} > 0 )); then
+ printf '%s\n' "${dirs[@]}" | sort -u > "$dest"
+ fi
+}
+
+find_new_blog_dir() {
+ local before="$1"
+ local after="$2"
+ local label="$3"
+ local new_dirs=()
+
+ mapfile -t new_dirs < <(comm -13 "$before" "$after" | sed '/^$/d')
+ if (( ${#new_dirs[@]} != 1 )); then
+ echo "Expected exactly one new BLOG debugfs directory for $label" >&2
+ echo "Before:" >&2
+ cat "$before" >&2
+ echo "After:" >&2
+ cat "$after" >&2
+ return 1
+ fi
+
+ echo "${new_dirs[0]}"
+}
+
+mount_ceph() {
+ local mnt="$1"
+ local opts="name=$CLIENT,noshare"
+
+ mkdir -p "$mnt"
+ [[ -n "$SECRET" ]] && opts="${opts},secret=$SECRET"
+ mount -t ceph "$MON":/ "$mnt" -o "$opts"
+}
+
+ensure_blog_dir() {
+ local dir="$1"
+
+ [[ -d "$dir" ]] || die "BLOG debugfs directory missing: $dir"
+ [[ -r "$dir/entries" ]] || die "BLOG entries file missing: $dir/entries"
+ [[ -r "$dir/stats" ]] || die "BLOG stats file missing: $dir/stats"
+ [[ -r "$dir/sources" ]] || die "BLOG sources file missing: $dir/sources"
+ [[ -r "$dir/clients" ]] || die "BLOG clients file missing: $dir/clients"
+ [[ -w "$dir/enabled" ]] || die "BLOG enabled file missing: $dir/enabled"
+}
+
+read_enabled() {
+ tr -d '[:space:]' < "$1/enabled"
+}
+
+set_blog_enabled() {
+ local dir="$1"
+ local value="$2"
+ local actual
+
+ echo "$value" > "$dir/enabled"
+ actual="$(read_enabled "$dir")"
+ case "$value" in
+ 0)
+ [[ "$actual" == "N" || "$actual" == "0" ]] ||
+ die "Failed to disable BLOG in $dir (got '$actual')"
+ ;;
+ 1)
+ [[ "$actual" == "Y" || "$actual" == "1" ]] ||
+ die "Failed to enable BLOG in $dir (got '$actual')"
+ ;;
+ *)
+ die "Unsupported BLOG enabled value: $value"
+ ;;
+ esac
+}
+
+clear_blog() {
+ local dir="$1"
+
+ echo "clear" > "$dir/clear"
+}
+
+count_entries() {
+ local dir="$1"
+ local count
+
+ count="$(grep -Ec '^[0-9]{4}-[0-9]{2}-[0-9]{2} ' "$dir/entries" 2>/dev/null || true)"
+ echo "${count:-0}"
+}
+
+count_sources() {
+ local dir="$1"
+ local count
+
+ count="$(grep -c '^ID ' "$dir/sources" 2>/dev/null || true)"
+ echo "${count:-0}"
+}
+
+count_contexts() {
+ local dir="$1"
+ local count
+
+ count="$(awk -F': ' '/Total contexts allocated/{print $2; exit}' \
+ "$dir/stats" 2>/dev/null || true)"
+ echo "${count:-0}"
+}
+
+count_mount_clients() {
+ local dir="$1"
+ local count
+
+ count="$(grep -c '^FSID:' "$dir/clients" 2>/dev/null || true)"
+ echo "${count:-0}"
+}
+
+require_timestamped_entries() {
+ local dir="$1"
+ local msg="$2"
+
+ if ! grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2} ' "$dir/entries"; then
+ fail "$msg: no timestamped BLOG entries found"
+ exit 1
+ fi
+ pass "$msg"
+}
+
+snapshot_blog() {
+ local dir="$1"
+ local tag="$2"
+ local dest="$OUTDIR/$tag"
+
+ mkdir -p "$dest"
+ for f in enabled entries stats sources clients; do
+ cat "$dir/$f" > "$dest/$f.txt"
+ done
+}
+
+run_mount_io() {
+ local mnt="$1"
+ local tag="$2"
+ local root="$mnt/blog_smoke_$tag"
+
+ rm -rf "$root"
+ mkdir -p "$root/subdir"
+ printf 'ceph-blog-%s %s\n' "$tag" "$(date +%s%N)" > "$root/file.txt"
+ cat "$root/file.txt" > /dev/null
+ stat "$root/file.txt" > /dev/null
+ mv "$root/file.txt" "$root/subdir/file.txt"
+ ln -sf "$root/subdir/file.txt" "$root/link.txt"
+ readlink "$root/link.txt" > /dev/null
+ ls -l "$root" > /dev/null
+ rm -f "$root/link.txt" "$root/subdir/file.txt"
+ rmdir "$root/subdir"
+ rmdir "$root"
+}
+
+echo "=========================================="
+echo " Ceph BLOG Per-Superblock Smoke Test"
+echo "=========================================="
+echo ""
+
+KSFT_SKIP=4
+info "Checking kernel BLOG support..."
+if ! have_kconfig 'CONFIG_CEPH_FS=(y|m)'; then
+ info "SKIP: Cannot confirm CONFIG_CEPH_FS=y|m"
+ exit "$KSFT_SKIP"
+fi
+if ! have_kconfig 'CONFIG_DEBUG_FS=y'; then
+ info "SKIP: Cannot confirm CONFIG_DEBUG_FS=y"
+ exit "$KSFT_SKIP"
+fi
+if have_kconfig 'CONFIG_CEPH_FS=m'; then
+ modprobe ceph 2>/dev/null || die "Failed to load ceph module"
+fi
+
+mkdir -p "$OUTDIR" "$MNT_A" "$MNT_B"
+TMP_STATE_DIR="$(mktemp -d /tmp/ceph_blog_smoke.XXXXXX)"
+
+capture_blog_dirs "$TMP_STATE_DIR/before_a"
+info "Mounting CephFS A with noshare: $MON -> $MNT_A"
+mount_ceph "$MNT_A" || die "Mount A failed"
+MOUNTED_A=1
+capture_blog_dirs "$TMP_STATE_DIR/after_a"
+DEBUGFS_A="$(find_new_blog_dir "$TMP_STATE_DIR/before_a" \
+ "$TMP_STATE_DIR/after_a" "mount A")" || die "Failed to identify BLOG dir for mount A"
+ensure_blog_dir "$DEBUGFS_A"
+pass "Mount A BLOG debugfs: $DEBUGFS_A"
+
+capture_blog_dirs "$TMP_STATE_DIR/before_b"
+info "Mounting CephFS B with noshare: $MON -> $MNT_B"
+mount_ceph "$MNT_B" || die "Mount B failed"
+MOUNTED_B=1
+capture_blog_dirs "$TMP_STATE_DIR/after_b"
+DEBUGFS_B="$(find_new_blog_dir "$TMP_STATE_DIR/before_b" \
+ "$TMP_STATE_DIR/after_b" "mount B")" || die "Failed to identify BLOG dir for mount B"
+ensure_blog_dir "$DEBUGFS_B"
+pass "Mount B BLOG debugfs: $DEBUGFS_B"
+
+assert_eq "$(count_mount_clients "$DEBUGFS_A")" 1 "mount A clients view is local"
+assert_eq "$(count_mount_clients "$DEBUGFS_B")" 1 "mount B clients view is local"
+
+info "Phase 1: BLOG disabled by default on both mounts"
+set_blog_enabled "$DEBUGFS_A" 0
+set_blog_enabled "$DEBUGFS_B" 0
+run_mount_io "$MNT_A" "disabled_a"
+assert_eq "$(count_entries "$DEBUGFS_A")" 0 "mount A stays quiet when disabled"
+assert_eq "$(count_entries "$DEBUGFS_B")" 0 "mount B stays quiet while mount A runs"
+snapshot_blog "$DEBUGFS_A" "00_disabled_a"
+snapshot_blog "$DEBUGFS_B" "00_disabled_b"
+
+info "Phase 2: Enable BLOG only on mount A"
+set_blog_enabled "$DEBUGFS_A" 1
+set_blog_enabled "$DEBUGFS_B" 0
+clear_blog "$DEBUGFS_A"
+clear_blog "$DEBUGFS_B"
+run_mount_io "$MNT_A" "enabled_a"
+
+A_ENTRIES="$(count_entries "$DEBUGFS_A")"
+B_ENTRIES="$(count_entries "$DEBUGFS_B")"
+assert_gt_zero "$A_ENTRIES" "mount A produces BLOG entries when enabled"
+assert_eq "$B_ENTRIES" 0 "mount B remains quiet while only mount A is enabled"
+assert_gt_zero "$(count_sources "$DEBUGFS_A")" "mount A registers BLOG sources"
+assert_gt_zero "$(count_contexts "$DEBUGFS_A")" "mount A allocates BLOG contexts"
+require_timestamped_entries "$DEBUGFS_A" "mount A entries deserialize with timestamps"
+snapshot_blog "$DEBUGFS_A" "01_enabled_a"
+snapshot_blog "$DEBUGFS_B" "01_enabled_a_other_mount"
+
+info "Phase 3: Disable mount A, enable mount B"
+A_BEFORE_B="$A_ENTRIES"
+set_blog_enabled "$DEBUGFS_A" 0
+set_blog_enabled "$DEBUGFS_B" 1
+clear_blog "$DEBUGFS_B"
+run_mount_io "$MNT_B" "enabled_b"
+
+A_AFTER_B="$(count_entries "$DEBUGFS_A")"
+B_AFTER_B="$(count_entries "$DEBUGFS_B")"
+assert_eq "$A_AFTER_B" "$A_BEFORE_B" "mount A entries stay unchanged while mount B runs"
+assert_gt_zero "$B_AFTER_B" "mount B produces BLOG entries when enabled"
+assert_gt_zero "$(count_sources "$DEBUGFS_B")" "mount B registers BLOG sources"
+assert_gt_zero "$(count_contexts "$DEBUGFS_B")" "mount B allocates BLOG contexts"
+require_timestamped_entries "$DEBUGFS_B" "mount B entries deserialize with timestamps"
+snapshot_blog "$DEBUGFS_A" "02_enabled_b_other_mount"
+snapshot_blog "$DEBUGFS_B" "02_enabled_b"
+
+echo ""
+echo "=========================================="
+echo " Results Summary"
+echo "=========================================="
+pass "Default-off behavior verified on both mounts"
+pass "Enable-on-A only behavior verified"
+pass "Cross-mount non-interference verified with noshare"
+pass "Enable-on-B behavior verified"
+info "Traces saved to: $OUTDIR/"
diff --git a/tools/testing/selftests/ceph_blog/config b/tools/testing/selftests/ceph_blog/config
new file mode 100644
index 000000000000..7c5706ed8be8
--- /dev/null
+++ b/tools/testing/selftests/ceph_blog/config
@@ -0,0 +1,2 @@
+CONFIG_CEPH_FS=m
+CONFIG_DEBUG_FS=y
--
2.34.1