#!/bin/bash
# ---------------------------------------------------------------------------
# KUnit engine.
#
# KUnit tests are kernel modules.  On RHEL 9 they are already built and
# shipped in kernel-modules-internal, so nothing has to be compiled: the
# module is loaded, it runs its suites during init, and the result is read
# back out of debugfs.
#
# Called by test-kernel.sh.  Can also be run on its own:
#     ./tests/run-kunit.sh <run-dir>
# ---------------------------------------------------------------------------
set -uo pipefail

KT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=../lib/common.sh
. "$KT_ROOT/lib/common.sh"
kt_load_settings

RUN_DIR=${1:-}
[ -n "$RUN_DIR" ] || kt_die "usage: run-kunit.sh <run-dir>"
mkdir -p "$RUN_DIR/kunit"
CSV="$RUN_DIR/results.csv"
LOG="$RUN_DIR/kunit/kunit.log"
DEBUGFS=/sys/kernel/debug

kt_head "KUnit"

# --- preflight -------------------------------------------------------------
MODDIR=/lib/modules/$(uname -r)
if [ ! -d "$MODDIR" ]; then
    kt_warn "no module directory for $(uname -r) -- skipping KUnit"
    kt_record "$CSV" kunit "" "(engine)" SKIP "" "no /lib/modules/$(uname -r)"
    exit 0
fi
if ! find "$MODDIR" -name 'kunit.ko*' -print -quit | grep -q .; then
    kt_warn "kunit.ko not found for $(uname -r)."
    kt_warn "Install kernel-modules-internal matching this exact kernel:"
    kt_warn "    dnf install kernel-modules-internal-\$(uname -r | sed 's/\\.\$(uname -m)\$//')"
    kt_record "$CSV" kunit "" "(engine)" SKIP "" "kernel-modules-internal not installed"
    exit 0
fi

# debugfs holds the results.  Remember whether we were the ones to mount it,
# so the machine is left the way we found it.
WE_MOUNTED_DEBUGFS=0
if ! mountpoint -q "$DEBUGFS"; then
    if mount -t debugfs none "$DEBUGFS" 2>>"$LOG"; then
        WE_MOUNTED_DEBUGFS=1
        kt_log "mounted debugfs at $DEBUGFS"
    else
        kt_warn "cannot mount debugfs -- results will be read from dmesg instead"
    fi
fi

# A test module can pull the kunit core in as a dependency, and an
# auto-loaded kunit gets no enable=1 on its command line.  A drop-in makes the
# option stick no matter how kunit is loaded.  It is removed again on exit.
MODPROBE_DROPIN=/etc/modprobe.d/zz-kernel-test-kunit.conf
DROPIN_CREATED=0
if [ ! -e "$MODPROBE_DROPIN" ] && [ -d /etc/modprobe.d ]; then
    if printf 'options kunit enable=1\n' > "$MODPROBE_DROPIN" 2>>"$LOG"; then
        DROPIN_CREATED=1
    fi
fi

cleanup() {
    [ "$DROPIN_CREATED" = 1 ] && rm -f "$MODPROBE_DROPIN"
    [ "$WE_MOUNTED_DEBUGFS" = 1 ] && umount "$DEBUGFS" 2>/dev/null
    return 0
}
trap cleanup EXIT

# RHEL builds with CONFIG_KUNIT_DEFAULT_ENABLED unset, so KUnit refuses to run
# ("kunit: disabled" in dmesg) until it is switched on.  The switch is only
# read when the kunit module is inserted, so an already-loaded kunit has to go
# first.  It cannot be removed while a test module still references it.
KUNIT_WAS_LOADED=0
if lsmod | grep -q '^kunit '; then
    KUNIT_WAS_LOADED=1
    modprobe -r kunit 2>>"$LOG" || kt_warn "kunit was already loaded and is in use; enable= may not take effect"
fi
if ! modprobe kunit enable=1 2>>"$LOG"; then
    kt_err "cannot load the kunit core module"
    kt_record "$CSV" kunit "" "(engine)" ERROR "" "modprobe kunit enable=1 failed"
    exit 0
fi
kt_log "kunit core loaded with enable=1"

# --- pick the modules ------------------------------------------------------
if [ "$KUNIT_MODULES" = auto ]; then
    mapfile -t MODULES < <(kt_list_kunit_modules)
else
    read -r -a MODULES <<< "$KUNIT_MODULES"
fi

# kunit itself is the framework, not a test.
FILTERED=()
for m in "${MODULES[@]:-}"; do
    [ -n "$m" ] || continue
    [ "$m" = kunit ] && continue
    if [ -n "${KUNIT_SKIP:-}" ] && printf '%s' "$m" | grep -Eq "$KUNIT_SKIP"; then
        kt_log "skip $m (matches KUNIT_SKIP)"
        kt_record "$CSV" kunit "$m" "(module)" SKIP "" "excluded by KUNIT_SKIP"
        continue
    fi
    FILTERED+=("$m")
done

if [ ${#FILTERED[@]} -eq 0 ]; then
    kt_warn "no KUnit test modules found"
    kt_record "$CSV" kunit "" "(engine)" SKIP "" "no KUnit modules for this kernel"
    exit 0
fi
kt_log "${#FILTERED[@]} KUnit modules to load"

# --- run -------------------------------------------------------------------
suites_now() {
    [ -d "$DEBUGFS/kunit" ] && ls -1 "$DEBUGFS/kunit" 2>/dev/null | sort || true
}

# Suites whose results have already been read during this run.  Comparing
# before/after listings alone is not enough: a module that cannot be unloaded
# leaves its suite directory behind, and the next module to register a suite
# of that name would then look like it produced nothing at all.
declare -A HARVESTED=()

PASS=0; FAIL=0; SKIP=0; ERR=0; DISABLED_SEEN=0
for m in "${FILTERED[@]}"; do
    printf '  %-34s' "$m"
    # Make sure the core is still there and still enabled.  Removing the
    # previous test module must not have taken it with it.
    lsmod | grep -q '^kunit ' || modprobe kunit enable=1 2>>"$LOG"
    before=$(suites_now)
    mark=$(kt_dmesg_mark)
    {
        echo "=============================================================="
        echo "module: $m"
        date
    } >> "$LOG"

    # modprobe returns non-zero when a suite failed, because KUnit propagates
    # the failure out of module_init.  That is a result, not a driver error,
    # so the rc is recorded but the results are read either way.
    timeout --kill-after=10s "${KUNIT_TIMEOUT}s" modprobe "$m" >>"$LOG" 2>&1
    rc=$?

    after=$(suites_now)
    # Anything visible that has not been read yet, not merely what is new
    # since this module was loaded.
    new_suites=""
    while IFS= read -r s; do
        [ -n "$s" ] || continue
        [ -n "${HARVESTED[$s]:-}" ] && continue
        new_suites+="$s"$'\n'
    done <<< "$after"

    rows_before=$(wc -l < "$CSV" 2>/dev/null || echo 0)
    if [ -n "${new_suites//[[:space:]]/}" ]; then
        while IFS= read -r s; do
            [ -n "$s" ] || continue
            res="$DEBUGFS/kunit/$s/results"
            [ -r "$res" ] || continue
            cp "$res" "$RUN_DIR/kunit/$s.ktap"
            if ! "$KT_PYTHON" "$KT_ROOT/lib/parse_results.py" kunit "$res" >> "$CSV"; then
                kt_record "$CSV" kunit "$s" "(module)" ERROR "" "could not parse the KTAP from $m"
            fi
            HARVESTED[$s]=1
        done <<< "$new_suites"
    else
        # No debugfs entry: the module may have failed to load, or debugfs is
        # not available.  KUnit also prints KTAP to the kernel log, so fall
        # back to that.
        # Every kernel log line starts with "[  1234.567890] ", so the
        # timestamps have to come off BEFORE looking for "KTAP version" --
        # checking the raw file can never match and the fallback would
        # silently do nothing.
        kt_dmesg_since "$mark" > "$RUN_DIR/kunit/$m.dmesg"
        sed -E 's/^\[[^]]*\] ?//' "$RUN_DIR/kunit/$m.dmesg" > "$RUN_DIR/kunit/$m.ktap"
        if grep -q '^[[:space:]]*KTAP version' "$RUN_DIR/kunit/$m.ktap"; then
            if ! "$KT_PYTHON" "$KT_ROOT/lib/parse_results.py" kunit \
                    "$RUN_DIR/kunit/$m.ktap" --collection "$m" >> "$CSV"; then
                kt_record "$CSV" kunit "$m" "(module)" ERROR "" "could not parse the KTAP in the kernel log"
            fi
        else
            rm -f "$RUN_DIR/kunit/$m.dmesg" "$RUN_DIR/kunit/$m.ktap"
        fi
    fi
    rows_after=$(wc -l < "$CSV" 2>/dev/null || echo 0)
    added=$(( rows_after - rows_before ))

    if [ "$added" -eq 0 ] && kt_dmesg_since "$mark" 2>/dev/null | grep -q 'kunit: disabled'; then
        kt_record "$CSV" kunit "$m" "(module)" ERROR "" "kunit core is loaded with enable=0"
        printf '%s%s%s\n' "$C_RED" "kunit disabled" "$C_OFF"
        ERR=$(( ERR + 1 ))
        DISABLED_SEEN=1
    elif [ "$added" -eq 0 ]; then
        if [ "$rc" -eq 0 ]; then
            # Loaded fine but produced no suites: the tests are compiled out.
            kt_record "$CSV" kunit "$m" "(module)" SKIP "" "loaded, no suites reported"
            printf '%s%s%s\n' "$C_DIM" "no suites" "$C_OFF"
        else
            kt_record "$CSV" kunit "$m" "(module)" ERROR "" "modprobe rc=$rc, no results"
            printf '%s%s%s\n' "$C_RED" "load failed (rc=$rc)" "$C_OFF"
            ERR=$(( ERR + 1 ))
        fi
    else
        f=$(grep -c '","FAIL"' <<< "$(tail -n "$added" "$CSV")")
        if [ "$f" -gt 0 ]; then
            printf '%s%s%s\n' "$C_RED" "$added cases, $f failed" "$C_OFF"
            FAIL=$(( FAIL + 1 ))
        else
            printf '%s%s%s\n' "$C_GRN" "$added cases ok" "$C_OFF"
            PASS=$(( PASS + 1 ))
        fi
    fi

    # Unload so the next module starts from a clean debugfs.  rmmod, not
    # "modprobe -r": modprobe -r also drops dependencies that are now unused,
    # which takes the kunit core away and leaves the next module to auto-load
    # it without enable=1 -- every later suite then reports nothing at all.
    # Some modules cannot be removed (they registered something permanent);
    # that is not an error, the suite names just stay visible and comm ignores
    # them.
    rmmod "${m//-/_}" 2>>"$LOG"

    # Anything the module did to the kernel log that looks like damage.
    if kt_scan_dmesg "$mark" "$RUN_DIR/kunit/$m.badness"; then
        kt_warn "  kernel log complained while running $m -- see kunit/$m.badness"
    else
        rm -f "$RUN_DIR/kunit/$m.badness"
    fi
done

# Put the kunit core back the way it was.
if [ "$KUNIT_WAS_LOADED" = 0 ]; then
    modprobe -r kunit 2>>"$LOG"
fi

if [ "$DISABLED_SEEN" = 1 ]; then
    kt_warn "KUnit reported \"disabled\".  Something else holds the kunit core"
    kt_warn "loaded with enable=0.  Reboot, or add kunit.enable=1 to the kernel"
    kt_warn "command line, then run again."
fi
kt_log "KUnit modules: $PASS clean, $FAIL with failures, $ERR could not load"
exit 0
