#!/usr/bin/env bash
# verify-release.sh -- verify a release's packages and source tarball against chacra,
# at one stage of the release pipeline. Read-only apart from its own log file
# (verify-<ver>-<stage>-<timestamp>.log in $VERIFY_LOG_DIR, default <prerelease>/logs,
# else ~/verify-logs); exits with the number of failed checks.
#
# Usage: verify-release.sh <stage> <package> <release> <version> <sha1>
#   post-pull     (run on the signer)     /opt/repos/<pkg>/<release>-<version> vs chacra
#   pre-promote   (run on the downloader) prerelease debs/rpms/tarball vs chacra
#   post-promote  (anywhere with HTTPS)   chacra-vs-download.sh + public tarball
#                                         (run from a checkout: needs chacra-vs-download.sh beside it)
# post-pull and pre-promote also work piped: ssh host 'bash -s -- <args>' < verify-release.sh
#   --selftest    run the built-in fixtures and exit
#
# Example (20.2.5):
#   verify-release.sh post-pull ceph tentacle 20.2.5 ed27c686770872022f4c1de04f6b0ac5d5384ded
#
# ponytail: exact filename sets + tarball size/gzip -t, no per-package sha256 (signing
# changes deb/rpm bytes); add a --deep tarball sha256 if size+gzip is ever not enough.

set -euo pipefail

CHACRA="https://chacra.ceph.com"
FLAVOR="default"
PRE_BASE="${PRE_BASE:-/data/download.ceph.com/www/prerelease/ceph}"
PROD_BASE="${PROD_BASE:-/data/download.ceph.com/www}"
HERE="$(dirname "$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo .)")"   # BASH_SOURCE unset when piped via `bash -s`
FAILS=0
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT

fetch() { curl -sf --max-time 30 "$1" 2>/dev/null || true; }
hrefs() { grep -oE 'href="[^"?/][^"]*"' | sed -E 's/href="([^"]*)"/\1/'; }
pass() { echo "  PASS  $*"; }
fail() { echo "  FAIL  $*"; FAILS=$((FAILS + 1)); }

# compare_sets <label> <expected-file> <actual-file>: exact sorted filename-set equality;
# an empty expected side is a failure, never a pass.
compare_sets() {
    local label="$1" exp="$2" act="$3" e a
    e=$(wc -l < "$exp"); a=$(wc -l < "$act")
    if [[ "$e" -eq 0 ]]; then fail "$label: chacra list is empty or unreachable"; return; fi
    if diff -q "$exp" "$act" >/dev/null; then pass "$label: $e files match chacra"; return; fi
    fail "$label: chacra=$e here=$a"
    comm -23 "$exp" "$act" | sed 's/^/        missing here: /'
    comm -13 "$exp" "$act" | sed 's/^/        extra here:   /'
}

# check_tarball <label> <file> <chacra-sizes>: real gzip, not truncated, size == one of chacra's.
check_tarball() {
    local label="$1" f="$2" want="$3" size
    [[ -f "$f" ]] || { fail "$label: missing $f"; return; }
    size=$(stat -c %s "$f")
    if [[ "$size" -lt 1048576 || "$(head -c2 "$f" | od -An -tx1 | tr -d ' ')" != "1f8b" ]]; then
        fail "$label: $f is $size bytes and not a gzip tarball"; return
    fi
    gzip -t "$f" 2>/dev/null || { fail "$label: gzip -t failed (truncated/corrupt) $f"; return; }
    [[ " $want " == *" $size "* ]] || { fail "$label: size $size not in chacra sizes: $want"; return; }
    pass "$label: $size bytes, gzip ok, equals a chacra tarball"
}

# Sizes (space-separated) of chacra's source tarball, one per ubuntu codename built.
# The codenames' tarballs differ in size (20.2.4: jammy 332988487, noble 332980650) and
# get-tarballs.sh picks one, so any of them is acceptable.
chacra_tarball_sizes() {
    local v len
    for v in $(python3 -c 'import json,sys; print(" ".join(json.load(sys.stdin).get("ubuntu",[])))' <<< "$DISTROS_JSON"); do
        len=$(curl -sIL --max-time 30 "$CHACRA/binaries/$PKG/$REL/$SHA1/ubuntu/$v/x86_64/flavors/$FLAVOR/ceph_${VER}-1${v}.tar.gz" \
              | tr -d '\r' | awk 'tolower($1)=="content-length:"{l=$2} /^HTTP/{c=$2} END{if(c==200)print l}')
        [[ -n "$len" ]] && printf '%s ' "$len"
    done
}

load_chacra() {
    DISTROS_JSON=$(fetch "$CHACRA/repos/$PKG/$REL/$SHA1/")
    [[ -n "$DISTROS_JSON" ]] || { echo "ERROR: chacra build not found: $CHACRA/repos/$PKG/$REL/$SHA1/" >&2; exit 2; }
}

# chacra .deb names: union over every debian/ubuntu pool (they overlap), one sorted file.
chacra_debs() {
    local d v
    while read -r d v; do
        fetch "$CHACRA/r/$PKG/$REL/$SHA1/$d/$v/flavors/$FLAVOR/pool/main/c/ceph/" | hrefs | grep '\.deb$' || true
    done < <(python3 -c 'import json,sys
for d,vs in json.load(sys.stdin).items():
    if d in ("debian","ubuntu"):
        for v in vs: print(d,v)' <<< "$DISTROS_JSON") | sort -u
}

# verify_tree <deb-root> <rpm-dir-cmd>: compare a local tree with chacra.
# rpm-dir-cmd is called as: <cmd> <distro> <distro-version> <arch>
verify_tree() {
    local deb_root="$1" rpm_dir_cmd="$2" d v arch dir
    chacra_debs > "$TMP/c_deb"
    if [[ -d "$deb_root" ]]; then
        find "$deb_root" -path '*/pool/*' -name '*.deb' -printf '%f\n' | sort -u > "$TMP/l_deb"
    else : > "$TMP/l_deb"; fi
    compare_sets "debs ($deb_root)" "$TMP/c_deb" "$TMP/l_deb"
    while read -r d v; do
        local archs
        archs=$(fetch "$CHACRA/r/$PKG/$REL/$SHA1/$d/$v/flavors/$FLAVOR/" | hrefs | grep '/$' | sed 's#/$##' | grep -v '^\.\.$' || true)
        [[ -n "$archs" ]] || { fail "rpms $d/$v: chacra lists no arch directories (unreachable or empty)"; continue; }
        for arch in $archs; do
            fetch "$CHACRA/r/$PKG/$REL/$SHA1/$d/$v/flavors/$FLAVOR/$arch/" | hrefs | grep '\.rpm$' | sort -u > "$TMP/c_rpm" || true
            dir=$($rpm_dir_cmd "$d" "$v" "$arch")
            if [[ -d "$dir" ]]; then find "$dir" -maxdepth 1 -name '*.rpm' -printf '%f\n' | sort -u > "$TMP/l_rpm"; else : > "$TMP/l_rpm"; fi
            compare_sets "rpms $d/$v/$arch" "$TMP/c_rpm" "$TMP/l_rpm"
        done
    done < <(python3 -c 'import json,sys
for d,vs in json.load(sys.stdin).items():
    if d in ("centos","rocky"):
        for v in vs: print(d,v)' <<< "$DISTROS_JSON")
}

signer_rpm_dir()     { echo "$SIGNER_BASE/$1/$2/$3"; }
downloader_rpm_dir() { echo "$PRE_BASE/rpm-$VER/el$2/$3"; }

stage_post_pull() {
    SIGNER_BASE="/opt/repos/$PKG/$REL-$VER"
    echo "post-pull: $SIGNER_BASE vs chacra $REL/$SHA1"
    # wrong host (e.g. the downloader) would otherwise print one "missing" failure per directory
    [[ -d "$SIGNER_BASE" ]] || { fail "$SIGNER_BASE not found on $(hostname) -- post-pull must run on the signer, after sync-pull"; return; }
    verify_tree "$SIGNER_BASE/debian" signer_rpm_dir
}

stage_pre_promote() {
    echo "pre-promote: $PRE_BASE (version $VER) vs chacra $REL/$SHA1"
    [[ -d "$PRE_BASE" ]] || { fail "$PRE_BASE not found on $(hostname) -- pre-promote must run on the downloader"; return; }
    verify_tree "$PRE_BASE/debian-$VER" downloader_rpm_dir
    local t; t=$(chacra_tarball_sizes)
    if [[ -z "$t" ]]; then fail "tarball: no chacra source tarball found for any ubuntu build"
    else check_tarball "prerelease tarball" "$PRE_BASE/tarballs/ceph-$VER.tar.gz" "$t"; fi
}

stage_post_promote() {
    echo "post-promote: public download.ceph.com vs chacra $REL/$SHA1"
    if [[ -x "$HERE/chacra-vs-download.sh" ]]; then
        "$HERE/chacra-vs-download.sh" "$REL" "$SHA1" "$VER" "$PKG" || FAILS=$((FAILS + $?))
    else
        fail "chacra-vs-download.sh not found next to this script (run post-promote from a checkout, not piped via bash -s)"
    fi
    # public tarball: sanity over HTTPS (size + gzip magic via a 2-byte range read)
    local t want pub="https://download.ceph.com/tarballs/ceph-$VER.tar.gz" size magic
    want=$(chacra_tarball_sizes)
    size=$(curl -sIL --max-time 30 "$pub" | tr -d '\r' | awk 'tolower($1)=="content-length:"{l=$2} END{print l}')
    magic=$(curl -s --max-time 30 -r 0-1 "$pub" | od -An -tx1 | tr -d ' ')
    if [[ "$magic" != "1f8b" || "${size:-0}" -lt 1048576 ]]; then fail "public tarball: ${size:-?} bytes, magic '${magic:-none}' -- not a gzip tarball ($pub)"
    elif [[ -z "$want" ]]; then fail "public tarball: no chacra tarball to compare against"
    elif [[ " $want " != *" $size "* ]]; then fail "public tarball: size $size not in chacra sizes: $want"
    elif ! curl -sf --max-time 900 "$pub" | gzip -t 2>/dev/null; then fail "public tarball: gzip -t failed (truncated/corrupt) $pub"
    else pass "public tarball: $size bytes, gzip -t ok, equals a chacra tarball"; fi
}

selftest() {
    local bad=0 d out; d=$(mktemp -d)
    printf 'a\nb\n' > "$d/e"; printf 'a\nb\n' > "$d/same"; printf 'a\nc\n' > "$d/diff"; : > "$d/empty"
    out=$(compare_sets t "$d/e" "$d/same"); [[ "$out" == *PASS* ]] || { echo "FAIL: identical sets"; bad=1; }
    out=$(compare_sets t "$d/e" "$d/diff"); [[ "$out" == *"missing here: b"* && "$out" == *"extra here:   c"* ]] || { echo "FAIL: diff sets"; bad=1; }
    out=$(compare_sets t "$d/empty" "$d/empty"); [[ "$out" == *FAIL* ]] || { echo "FAIL: empty expected must fail"; bad=1; }
    seq 1 2000000 | gzip > "$d/good.gz"; sz=$(stat -c %s "$d/good.gz")
    head -c 2000000 /dev/zero | tr '\0' 'x' > "$d/text.gz"
    head -c 1500000 "$d/good.gz" > "$d/trunc.gz"
    out=$(check_tarball t "$d/good.gz" "$sz"); [[ "$out" == *PASS* ]] || { echo "FAIL: good tarball"; bad=1; }
    out=$(check_tarball t "$d/good.gz" 1); [[ "$out" == *"size"* && "$out" == *FAIL* ]] || { echo "FAIL: size mismatch"; bad=1; }
    out=$(check_tarball t "$d/text.gz" "$sz"); [[ "$out" == *"not a gzip"* ]] || { echo "FAIL: text file"; bad=1; }
    out=$(check_tarball t "$d/trunc.gz" "$sz"); [[ "$out" == *"gzip -t failed"* ]] || { echo "FAIL: truncated gzip"; bad=1; }
    out=$(check_tarball t "$d/none.gz" "$sz"); [[ "$out" == *missing* ]] || { echo "FAIL: missing file"; bad=1; }
    # rpm distro reported by chacra but no arch dirs listed must FAIL, not silently pass
    fetch() { :; }
    PKG=ceph REL=tentacle SHA1=0 DISTROS_JSON='{"centos":["9"]}' FAILS=0
    out=$(verify_tree "$d/none" signer_rpm_dir); [[ "$out" == *"lists no arch directories"* ]] || { echo "FAIL: empty arch discovery passed silently"; bad=1; }
    # wrong host: one clear failure, not one per directory
    PKG=no-such-pkg REL=tentacle VER=0.0.0 FAILS=0
    out=$(stage_post_pull); [[ "$out" == *"must run on the signer"* && "$(grep -c FAIL <<< "$out")" -eq 1 ]] || { echo "FAIL: post-pull wrong-host guard"; bad=1; }
    PRE_BASE="$d/no-prerelease"
    out=$(stage_pre_promote); [[ "$out" == *"must run on the downloader"* && "$(grep -c FAIL <<< "$out")" -eq 1 ]] || { echo "FAIL: pre-promote wrong-host guard"; bad=1; }
    rm -rf "$d"
    [[ $bad -eq 0 ]] && echo "selftest: PASS" || echo "selftest: FAIL"
    return $bad
}

[[ "${1:-}" == "--selftest" ]] && { selftest; exit $?; }
if [[ $# -ne 5 ]]; then sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 1; fi
STAGE="$1" PKG="$2" REL="$3" VER="$4" SHA1="$5"
[[ "$SHA1" =~ ^[0-9a-f]{40}$ ]] || { echo "ERROR: sha1 must be 40 hex chars" >&2; exit 1; }

case "$STAGE" in post-pull|pre-promote|post-promote) ;; *) echo "ERROR: unknown stage '$STAGE'" >&2; exit 1 ;; esac

# Every run is tee'd to a timestamped log: $VERIFY_LOG_DIR, else <prerelease>/logs
# (beside $PRE_BASE), else ~/verify-logs when that isn't writable (e.g. a laptop).
LOG_DIR="${VERIFY_LOG_DIR:-$(dirname "$PRE_BASE")/logs}"
mkdir -p "$LOG_DIR" 2>/dev/null && [[ -w "$LOG_DIR" ]] || { LOG_DIR="$HOME/verify-logs"; mkdir -p "$LOG_DIR"; }
LOG="$LOG_DIR/verify-${VER}-${STAGE}-$(date +%Y%m%d-%H%M%S).log"

run_stage() {
    echo "[$(date '+%F %T')] verify-release.sh $STAGE $PKG $REL $VER $SHA1 (host $(hostname))"
    load_chacra
    case "$STAGE" in
        post-pull)    stage_post_pull ;;
        pre-promote)  stage_pre_promote ;;
        post-promote) stage_post_promote ;;
    esac
    echo "----"
    [[ $FAILS -eq 0 ]] && echo "ALL CHECKS PASSED" || echo "$FAILS CHECK(S) FAILED"
    echo "log: $LOG"
    exit "$FAILS"
}
# subshell so its exit status (the failure count) survives the pipe to tee
( run_stage ) 2>&1 | tee "$LOG"
exit "${PIPESTATUS[0]}"
