#!/bin/bash
# Headless Leita measurement on one constrained host.
# Writes a deterministic fixture, drops only that fixture's file cache,
# and runs leita inside a transient systemd unit with CPU, memory and
# I/O caps. Does not drop the global page cache, install leita journal,
# enable a real scheduler unit, reboot, or write outside the work directory.
# The published results are the run that produced results.json beside
# this script; a later run is a different measurement.
# Filenames cold.json and warm.json are phase labels. fincore RES is the
# file-cache measurement. disk_cold in results.json stays false unless a
# later method establishes a disk-cold read.
set -euo pipefail

expected_sha=2db0b50fdecd3b955f72b936003724a340efd76563d709f03f17a9dc40ee4b1c
expected_version='leita gc9f3469 (linux/amd64, built 2026-10-14)'
seed=615-vps-1
disclosure='Synthetic fixture. Not a customer and not a sample of anyone'\''s files.'
memory_max=134217728
io_bps=8388608
cpu_quota=25%
mib=1048576
label_bytes=4096
before_bytes=$((mib * 3 + label_bytes))
after_bytes=$((mib + mib * 2 + label_bytes + mib))

die() { printf 'leita-615: %s\n' "$*" >&2; exit 1; }

[ "$#" -eq 3 ] || die "usage: run.sh WORK_DIR LEITA_BIN GLIBC_DIR"
work=$1
leita=$2
glibc_src=$3
case "$work" in
  /tmp/leita615.*) ;;
  *) die "work directory must be /tmp/leita615.*" ;;
esac
[ ! -e "$work" ] || die "work directory already exists: $work"
out=$work/out
fix=$work/fixture
home=$work/home
data=$work/xdg/data
config=$work/xdg/config
cache=$work/xdg/cache
state=$work/xdg/state
runtime=$work/xdg/runtime
stub=$work/stub
units=(leita615-version leita615-cold leita615-warm leita615-base leita615-incr leita615-html leita615-journal leita615-monlist leita615-monadd leita615-monon leita615-monrun)

cleanup() {
  local u
  for u in "${units[@]}"; do
    systemctl stop "$u.service" >/dev/null 2>&1 || true
    systemctl reset-failed "$u.service" >/dev/null 2>&1 || true
  done
}
trap cleanup EXIT

command -v systemd-run >/dev/null || die "systemd-run is missing"
command -v perl >/dev/null || die "perl is missing"
command -v fincore >/dev/null || die "fincore is missing"
command -v jq >/dev/null || die "jq is missing"
time_bin=/run/current-system/sw/bin/time
[ -x "$time_bin" ] || die "GNU time is missing at $time_bin"
bash_bin=$(readlink -f /run/current-system/sw/bin/bash)
[ -x "$bash_bin" ] || die "bash is missing at $bash_bin"
"$time_bin" --version | head -n 1 | grep -q 'GNU Time' || die "time at $time_bin is not GNU time"
[ "$(id -u)" -eq 0 ] || die "this measurement is the root cgroup on the host"
avail=$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)
[ "$avail" -ge 200000 ] || die "MemAvailable ${avail} kB is below the 200000 kB floor"
awk -v l="$(cut -d' ' -f1 /proc/loadavg)" 'BEGIN { exit !(l+0 < 1.5) }' || die "load is at least 1.5"
df_avail=$(df -B1 --output=avail /tmp | awk 'NR==2 {print $1}')
[ "$df_avail" -ge 1073741824 ] || die "less than 1 GiB free on /tmp"

if [ -e /root/.local/share/leita ] || [ -e /root/.config/leita ] || [ -e /etc/systemd/system/leita-journal.service ] || [ -e /etc/systemd/system/leita-monitor.service ] || [ -e /etc/systemd/system/leita-monitor.timer ]; then
  die "host already has leita state or a leita unit; refusing to touch it"
fi

install -d -m 0700 "$work" "$out" "$home" "$data" "$config" "$cache" "$state" "$runtime" "$stub" "$fix" "$work/glibc"
install -m 0755 "$leita" "$work/leita"
got_sha=$(sha256sum "$work/leita" | awk '{print $1}')
[ "$got_sha" = "$expected_sha" ] || die "leita sha256 is $got_sha"
for lib in ld-linux-x86-64.so.2 libc.so.6 libm.so.6; do
  [ -f "$glibc_src/$lib" ] || die "missing $glibc_src/$lib"
  install -m 0755 "$glibc_src/$lib" "$work/glibc/$lib"
done
# Record a direct exec, then start the binary with the private loader
# copied into the work directory. The script does not inspect the host loader.
set +e
"$work/leita" --version > "$out/direct-exec.txt" 2>&1
direct_rc=$?
set -e
printf '%s\n' "$direct_rc" > "$out/direct-exec.rc"
got_version=$("$work/glibc/ld-linux-x86-64.so.2" --library-path "$work/glibc" "$work/leita" --version 2>&1 | head -n 1)
[ "$got_version" = "$expected_version" ] || die "leita version is $got_version"

# systemctl and loginctl stubs so `leita monitor on` cannot load a unit
# into the real service manager. The stub records the argv and fails.
cat > "$stub/systemctl" << 'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "${LEITA615_STUB_LOG:?}"
printf 'refused by isolation stub: systemctl %s\n' "$*" >&2
exit 1
EOF
cat > "$stub/loginctl" << 'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "${LEITA615_STUB_LOG:?}"
printf 'refused by isolation stub: loginctl %s\n' "$*" >&2
exit 1
EOF
chmod 0755 "$stub/systemctl" "$stub/loginctl"
: > "$out/stub.log"

cat > "$work/wrap.sh" << EOF
#!${bash_bin}
set -euo pipefail
timefile=\$1
summary=\$2
errfile=\$3
home=\$4
data=\$5
config=\$6
cache=\$7
state=\$8
runtime=\$9
path_prefix=\${10}
stub_log=\${11}
leita=\${12}
shift 12
export HOME="\$home"
export XDG_DATA_HOME="\$data"
export XDG_CONFIG_HOME="\$config"
export XDG_CACHE_HOME="\$cache"
export XDG_STATE_HOME="\$state"
export XDG_RUNTIME_DIR="\$runtime"
export LEITA615_STUB_LOG="\$stub_log"
if [ "\$path_prefix" = "-" ]; then
  export PATH=/run/current-system/sw/bin
else
  export PATH="\$path_prefix:/run/current-system/sw/bin"
fi
unset LEITA_LICENCE_KEY LEITA_LICENSE LEITA_COLOR LEITA_LICENCE
set +e
${time_bin} -f 'elapsed_sec %e
max_rss_kb %M
user_sec %U
sys_sec %S
exit_code %x' -o "\$timefile" -- ${work}/glibc/ld-linux-x86-64.so.2 --library-path ${work}/glibc "\$leita" "\$@" > "\$summary" 2> "\$errfile"
rc=\$?
set -e
cg=\$(awk -F: 'NR==1 {print \$3}' /proc/self/cgroup)
{
  printf 'cgroup %s\n' "\$cg"
  printf 'memory.max %s\n' "\$(cat /sys/fs/cgroup\$cg/memory.max)"
  printf 'memory.peak %s\n' "\$(cat /sys/fs/cgroup\$cg/memory.peak)"
  printf 'memory.swap.max %s\n' "\$(cat /sys/fs/cgroup\$cg/memory.swap.max)"
  printf 'cpu.max %s\n' "\$(cat /sys/fs/cgroup\$cg/cpu.max)"
  printf 'io.max %s\n' "\$(cat /sys/fs/cgroup\$cg/io.max)"
  printf 'io.stat %s\n' "\$(tr '\n' ';' < /sys/fs/cgroup\$cg/io.stat)"
} > "\${timefile%.time}.cgroup"
exit "\$rc"
EOF
chmod 0755 "$work/wrap.sh"

write_label() {
  perl -e '
    my $path = $ARGV[0];
    my $text = $ARGV[1];
    my $n = $ARGV[2];
    die "label longer than slot\n" if length($text) > $n;
    open my $fh, ">", $path or die $!;
    print $fh $text, "\0" x ($n - length($text));
    close $fh or die $!;
  ' "$1" "$2" "$3"
}

mkdir -p "$fix/keep" "$fix/grow" "$fix/drop" "$fix/note"
dd if=/dev/zero of="$fix/keep/keep.bin" bs=$mib count=1 status=none
dd if=/dev/zero of="$fix/grow/grow.bin" bs=$mib count=1 status=none
dd if=/dev/zero of="$fix/drop/drop.bin" bs=$mib count=1 status=none
write_label "$fix/note/label.txt" "$disclosure"$'\n'"seed $seed"$'\n' "$label_bytes"

mapfile -d '' files < <(find "$fix" -type f -print0 | sort -z)
[ "${#files[@]}" -eq 4 ] || die "expected 4 fixture files, found ${#files[@]}"

drop_file_cache() {
  perl -e '
    use Fcntl;
    sysopen(my $fh, $ARGV[0], O_RDWR) or die "open $ARGV[0]: $!";
    my $fd = fileno($fh);
    syscall(74, $fd) == 0 or die "fsync $ARGV[0]: $!";
    my $n = -s $ARGV[0];
    syscall(221, $fd, 0, $n, 4) == 0 or die "fadvise $ARGV[0]: $!";
  ' "$1"
}

for f in "${files[@]}"; do
  drop_file_cache "$f"
done

fincore_tsv() {
  local dest=$1
  shift
  fincore -b -n -o RES,SIZE,FILE "$@" > "$dest"
}

fincore_tsv "$out/cache-before-cold.tsv" "${files[@]}"
resident_before_cold=$(awk '{s += $1} END {print s + 0}' "$out/cache-before-cold.tsv")

vda_line() { awk '$3 == "vda" {print; exit}' /proc/diskstats; }
vda_before=$(vda_line)

run_limited() {
  local name=$1
  shift
  local unit="leita615-$name"
  local timefile=$out/$name.time
  local summary=$out/$name.summary
  local errfile=$out/$name.err
  local rc=0
  set +e
  systemd-run --unit="$unit" --wait --working-directory="$out" \
    -p MemoryAccounting=yes \
    -p IOAccounting=yes \
    -p MemoryMax=$memory_max \
    -p MemorySwapMax=0 \
    -p CPUQuota=$cpu_quota \
    -p TasksMax=64 \
    -p IOReadBandwidthMax="/dev/vda $io_bps" \
    -p IOWriteBandwidthMax="/dev/vda $io_bps" \
    -- "$work/wrap.sh" "$timefile" "$summary" "$errfile" \
      "$home" "$data" "$config" "$cache" "$state" "$runtime" \
      "$scan_path" "$out/stub.log" \
      "$work/leita" "$@" > "$out/$name.systemd" 2>&1
  rc=$?
  set -e
  printf '%s\n' "$rc" > "$out/$name.rc"
  systemctl show "$unit.service" \
    -p MemoryMax -p MemoryPeak -p MemorySwapMax \
    -p CPUQuotaPerSecUSec -p CPUUsageNSec \
    -p TasksMax -p IOReadBandwidthMax -p IOWriteBandwidthMax \
    -p IOReadBytes -p IOWriteBytes -p Result -p ExecMainStatus \
    > "$out/$name.unit" 2>/dev/null || true
  local cgfile=$out/$name.cgroup
  [ -f "$cgfile" ] || die "$unit wrote no cgroup record"
  local mem cpu io
  mem=$(awk '$1 == "memory.max" {print $2}' "$cgfile")
  cpu=$(awk '$1 == "cpu.max" {print $2, $3}' "$cgfile")
  io=$(awk '$1 == "io.max" {print}' "$cgfile")
  [ "$mem" = "$memory_max" ] || die "$unit memory.max is $mem"
  case "$cpu" in
    max*) die "$unit cpu.max is unlimited ($cpu)" ;;
  esac
  case "$io" in
    *"$io_bps"*) ;;
    *) die "$unit io.max does not contain $io_bps ($io)" ;;
  esac
  systemctl stop "$unit.service" >/dev/null 2>&1 || true
  systemctl reset-failed "$unit.service" >/dev/null 2>&1 || true
  return "$rc"
}

scan_path=-

# --version starts the binary under the same caps. The wrapper writes
# the live cgroup files, and run_limited checks those.
run_limited version --version || die "version under the cgroup failed"

run_limited cold --gentle --color never --no-history --whole --top 20 \
  --report "$out/cold.json" --format json "$fix" || die "first unbuffered scan failed"
vda_after=$(vda_line)
fincore_tsv "$out/cache-after-cold.tsv" "${files[@]}"
resident_after_cold=$(awk '{s += $1} END {print s + 0}' "$out/cache-after-cold.tsv")

# "warm" is only the output filename for a second --no-history --whole
# scan. Read the fincore RES column before describing a cache condition.
# results.json sets disk_cold false and never sets it true.
fincore_tsv "$out/cache-before-warm.tsv" "${files[@]}"
resident_before_warm=$(awk '{s += $1} END {print s + 0}' "$out/cache-before-warm.tsv")
run_limited warm --gentle --color never --no-history --whole --top 20 \
  --report "$out/warm.json" --format json "$fix" || die "repeat unbuffered scan failed"
fincore_tsv "$out/cache-after-warm.tsv" "${files[@]}"

run_limited base --gentle --color never --top 20 \
  --report "$out/baseline.json" --format json "$fix" || die "baseline scan failed"

# Mutation: grow one file, remove one, add one. The empty directory stays.
dd if=/dev/zero bs=$mib count=1 status=none >> "$fix/grow/grow.bin"
rm -f "$fix/drop/drop.bin"
mkdir -p "$fix/new"
dd if=/dev/zero of="$fix/new/new.bin" bs=$mib count=1 status=none
drop_file_cache "$fix/grow/grow.bin"
drop_file_cache "$fix/new/new.bin"
# Advise those two files, then record residency. Do not describe a cache
# condition from the advice alone.
mapfile -d '' after_files < <(find "$fix" -type f -print0 | sort -z)
fincore_tsv "$out/cache-before-incremental.tsv" "${after_files[@]}"
run_limited incr --gentle --color never --top 20 \
  --report "$out/incremental.json" --format json "$fix" || die "incremental scan failed"
run_limited html --gentle --color never --no-history --whole --top 20 \
  --report "$out/incremental.html" --format html "$fix" || die "html scan failed"

# Schedule and journal stay inside the cgroup and the work directory.
# monitor on gets the failing stubs. journal on is not invoked.
run_limited journal journal status || true
run_limited monlist monitor list || true
run_limited monadd monitor add "$fix" --every hourly || true
scan_path=$stub
run_limited monon monitor on || true
scan_path=-
run_limited monrun monitor run --all || true

if [ -e /root/.local/share/leita ] || [ -e /root/.config/leita ] || [ -e /etc/systemd/system/leita-journal.service ] || [ -e /etc/systemd/system/leita-monitor.service ] || [ -e /etc/systemd/system/leita-monitor.timer ] || [ -e /root/.config/systemd/user/leita-monitor.service ] || [ -e /root/.config/systemd/user/leita-monitor.timer ]; then
  die "leita state appeared outside the work directory"
fi

. /etc/os-release
{
  printf 'pretty %s\n' "$PRETTY_NAME"
  printf 'kernel %s\n' "$(uname -sr)"
  printf 'machine %s\n' "$(uname -m)"
  printf 'hostname %s\n' "$(hostname)"
  printf 'nproc %s\n' "$(nproc)"
  printf 'load %s\n' "$(cut -d' ' -f1-3 /proc/loadavg)"
  awk '/MemTotal:|MemAvailable:|SwapTotal:/ {print}' /proc/meminfo
  findmnt -T "$fix" -o TARGET,SOURCE,FSTYPE,OPTIONS -n
  lsblk -b -d -o NAME,SIZE,ROTA,TYPE /dev/vda
  stat -c 'vda_dev %t:%T' /dev/vda
  printf 'time %s\n' "$("$time_bin" --version | head -n 1)"
  printf 'fincore %s\n' "$(fincore --version | head -n 1)"
  printf 'perl %s\n' "$(perl -e 'print $^V')"
  printf 'systemd %s\n' "$(systemctl --version | head -n 1)"
  host_libc=$(ldd /run/current-system/sw/bin/perl | awk '/libc\.so\.6/ {print $3; exit}')
  printf 'host_libc_path %s\n' "$host_libc"
  host_banner=$("$host_libc" 2>&1 | head -n 1 || true)
  private_banner=$("$work/glibc/libc.so.6" 2>&1 | head -n 1 || true)
  printf 'host_libc_banner %s\n' "$host_banner"
  printf 'private_libc_banner %s\n' "$private_banner"
  sha256sum "$work/glibc/ld-linux-x86-64.so.2" "$work/glibc/libc.so.6" "$work/glibc/libm.so.6"
} > "$out/host.txt"

jq -n \
  --arg seed "$seed" \
  --arg disclosure "$disclosure" \
  --arg sha "$got_sha" \
  --arg version "$got_version" \
  --arg work "$work" \
  --arg fix "$fix" \
  --argjson before "$before_bytes" \
  --argjson after "$after_bytes" \
  --argjson resident_before_cold "$resident_before_cold" \
  --argjson resident_after_cold "$resident_after_cold" \
  --argjson resident_before_warm "$resident_before_warm" \
  --arg vda_before "$vda_before" \
  --arg vda_after "$vda_after" \
  --arg memory_max "$memory_max" \
  --arg io_bps "$io_bps" \
  --arg cpu_quota "$cpu_quota" \
  '{
    seed: $seed,
    disclosure: $disclosure,
    binary: {sha256: $sha, version: $version, identified: "local build copied into the work directory, not a release asset", loader: "private glibc copy in the work directory, because the host stub loader and host glibc cannot start this binary"},
    work: $work,
    fixture: $fix,
    expected_bytes: {before: $before, after: $after},
    limits: {memory_max: ($memory_max|tonumber), memory_swap_max: 0, cpu_quota: $cpu_quota, tasks_max: 64, io_read_bps: ($io_bps|tonumber), io_write_bps: ($io_bps|tonumber), device: "/dev/vda"},
    cache: {
      method: "per-file sync (fsync syscall 74) then posix_fadvise POSIX_FADV_DONTNEED (fadvise64 syscall 221) on each fixture file",
      verifier: "util-linux fincore RES column, in bytes",
      resident_bytes_before_cold: $resident_before_cold,
      resident_bytes_after_cold: $resident_after_cold,
      resident_bytes_before_warm: $resident_before_warm,
      disk_cold: false,
      diskstats_vda_before: $vda_before,
      diskstats_vda_after_cold: $vda_after,
      not_disk_cold: "Global drop_caches was not used. Directory and inode caches were not dropped. A /proc/diskstats delta does not show that the hypervisor or the physical device served the bytes."
    }
  }' > "$out/results.json"

printf 'wrote %s\n' "$out/results.json"
