#!/usr/bin/env bash
#
# preflight.sh — answer, in seconds, the questions a mint would answer in minutes.
#
#   build/preflight.sh fw13        # check fw13's recipe before building it
#   build/preflight.sh fw13 --fast # the instant checks only, no probe
#
# Exit codes, and the split matters because both builders gate on it:
#
#   0  clean
#   1  findings about the image; do not build
#   2  usage: no host named, or no recipe by that name
#   4  findings about this host alone; the medium is fine
#
# See the comment above the exit at the bottom for why those are two codes.
#
# WHY THIS EXISTS. Every assertion Alloy has lives inside the Containerfile, so
# running one costs a build up to that step, and `bootc container lint` is step
# 101 of 103 — a single undeclared file in /var costs the whole image. On
# 2026-09-04 four mints in a row each found one defect and were thrown away.
# Three of those four were answerable with `dnf repoquery` against the same
# repos, without installing anything:
#
#   undeclared /var content   repoquery -l postgresql16-server
#   no webkit                 repoquery --whatprovides 'pkgconfig(webkit2gtk-4.1)'
#   no pg_trgm                repoquery --whatprovides '*/pg_trgm.control'
#
# The fourth was not, and is the honest limit of this script: linuxdeploy bundles
# its own binutils and cannot read Fedora 43's `.relr.dyn` sections, which is a
# runtime interaction no static check finds. What WAS catchable there is that the
# workaround had existed before and was lost, which is what the `guards` check is.
#
# It runs in a container from the base image rather than on the dev host, because
# the questions are about Fedora's repos and fw13 is Pop. That also means it
# needs no dnf, no repo config and no Fedora anything on the machine running it.
#
# Not a substitute for the Containerfile's own assertions. Those run against the
# built image and prove what IS; this runs against the repos and predicts what
# WOULD BE. Both are wanted: this one is fast and can be wrong, that one is slow
# and cannot.

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$REPO_ROOT/build/privilege.sh"

HOST="${1:-}"
[ -n "$HOST" ] || {
    printf 'usage: build/preflight.sh <host>\n' >&2
    printf 'known hosts: %s\n' "$(cd "$REPO_ROOT/build/hosts" && ls -1 ./*.env | sed 's|^\./||; s|\.env$||' | tr '\n' ' ')" >&2
    exit 2
}
[ -f "$REPO_ROOT/build/hosts/$HOST.env" ] || {
    printf 'preflight: no recipe for host %s\n' "$HOST" >&2
    exit 2
}

# The base the Containerfile builds FROM, so repoquery sees the same repos the
# build will. Pinned by digest there; read it back rather than repeating it, so
# this cannot drift from what is actually built.
BASE="$(grep -m1 -oE 'registry\.fedoraproject\.org/fedora-bootc:[0-9]+@sha256:[0-9a-f]+' "$REPO_ROOT/Containerfile")"
[ -n "$BASE" ] || { echo "preflight: could not read the base image from the Containerfile" >&2; exit 1; }

# `privc`, not `priv`: this output is read as a report and captured in CI, and
# run0 without --pipe puts its child on a pty, which staples a carriage return to
# every line. Not `exec`, because privc is a shell function.
out="$(mktemp)"; probe=""
# The `|| :` is load-bearing, and its absence was a bug this script carried from
# the start. bash lets an EXIT trap set the script's exit status, and
# `[ -n "$probe" ] && rm -rf "$probe"` ends on a false test whenever the probe
# was never built -- which is every --fast run. So a clean --fast run printed
# "preflight: clean" and exited 1. Invisible while nothing read the code, and
# fatal the moment the builders started gating on it: --fast is exactly what a
# cross-architecture mint runs, so astra's every mint would have been refused.
cleanup() { rm -f "$out"; [ -n "$probe" ] && rm -rf "$probe"; :; }
trap cleanup EXIT

# `set -e` with `pipefail` would end the script the moment the resolver reports a
# finding, which is the one case this exists for. Findings are data here, not
# errors, so the status is read rather than acted on.
set +e
privc podman run --rm \
    -v "$REPO_ROOT:/repo:ro" \
    --entrypoint python3 \
    "$BASE" /repo/build/preflight.py "$HOST" | tee "$out"
rc="${PIPESTATUS[0]}"
set -e

# ------------------------------------------------------------------ the probe ---
# The remaining two checks are answered by a probe image rather than predicted.
#
# The first version predicted both from `dnf repoquery`, and both were wrong in
# the same way. var-payload derived bootc's rule from the symptom and reported
# paths the real build accepts. requires compared a capability's provider against
# the packages the Containerfile names, so `bin:git` failed because /usr/bin/git
# comes from `git-core` while the recipe installs `git` -- a false failure, and a
# check that cries wolf stops being read.
#
# Both go away by building the smallest image that can be asked directly: this
# mint's package set plus the tmpfiles files it would ship. `command -v` and
# `bootc container lint` then observe rather than predict. It is a build, which
# is what this script exists to avoid, but it is one dnf transaction rather than
# 103 steps, and it is right rather than close.
#
# WHAT THE PROBE CANNOT SEE, stated here rather than discovered later: anything
# that reaches /var from something other than a package. The real Containerfile
# copies etc/ and usr/ and runs its own steps, and any of those could leave a
# file the probe never carries. The class it does catch is the one that has
# actually bitten -- a package's %post leaving content behind.
if [ "$rc" -ne 0 ]; then
    # The dials failed, so the recipe describes an image the Containerfile's own
    # validator refuses. Everything the probe would measure is a property of a
    # mint that cannot happen, so building it costs a dnf transaction to answer
    # questions about nothing. It also makes the gate slow precisely when it is
    # about to refuse, and a gate people wait on is a gate people route around.
    echo
    echo "== probe: skipped; the dials above are already a refusal"
elif [ "${2:-}" = "--fast" ]; then
    echo
    echo "== probe: skipped (--fast); dials, modes and guards only"
else
    pkgset="$(sed -n 's/^PKGSET: //p' "$out")"
    drop="$(sed -n 's/^TMPFILES-DROP: //p' "$out")"
    if [ -z "$pkgset" ]; then
        echo; echo "== probe: SKIP no package set came back from the resolver"
    else
        probe="$(mktemp -d)"
        mkdir -p "$probe/tmpfiles"
        cp "$REPO_ROOT"/usr/lib/tmpfiles.d/*.conf "$probe/tmpfiles/"
        for f in $drop; do rm -f "$probe/tmpfiles/$f"; done
        if [ -f "$REPO_ROOT/build/hosts/$HOST.requires" ]; then
            cp "$REPO_ROOT/build/hosts/$HOST.requires" "$probe/requires"
        else
            : > "$probe/requires"
        fi

        # Run inside the probe: what the role needs, asked of a filesystem.
        cat > "$probe/check-requires.sh" <<'CHECK'
#!/bin/sh
# Each requirement, asked of the image rather than of the repos.
bad=0; any=0
while IFS= read -r line; do
    line="${line%%#*}"
    line="$(printf '%s' "$line" | tr -d '[:space:]')"
    [ -n "$line" ] || continue
    any=1
    kind="${line%%:*}"; value="${line#*:}"
    case "$kind" in
        bin)       command -v "$value" >/dev/null 2>&1 ;;
        pkgconfig) pkg-config --exists "$value" 2>/dev/null ;;
        file)      find / -path "$value" -print -quit 2>/dev/null | grep -q . ;;
        *)         printf '   SKIP %-34s unknown requirement kind
' "$line"; continue ;;
    esac
    if [ $? -eq 0 ]; then
        printf '   ok   %s
' "$line"
    else
        printf '   FAIL %s
' "$line"
        # Only now is repoquery worth the wait, and only for what is missing.
        case "$kind" in
            bin)       q="/usr/bin/$value" ;;
            pkgconfig) q="pkgconfig($value)" ;;
            file)      q="$value" ;;
        esac
        c="$(dnf -q repoquery --qf '%{name}
' --whatprovides "$q" 2>/dev/null | sort -u | head -4 | tr '
' ' ')"
        [ -n "$c" ] && printf '        add one of: %s
' "$c"
        bad=1
    fi
done < /requires
[ "$any" = 1 ] || echo "   SKIP this host declares no role contract"
exit "$bad"
CHECK
        chmod +x "$probe/check-requires.sh"

        cat > "$probe/Containerfile" <<PROBE
FROM $BASE
RUN set -eux; dnf install -y --skip-unavailable --setopt=install_weak_deps=False $pkgset; \\
    dnf clean all; \\
    rm -f /var/log/dnf5.log*; \\
    rm -rf /var/lib/dnf /var/cache/libdnf5; \\
    rm -f /var/cache/ldconfig/aux-cache; \\
    rm -f /var/cache/swcatalog/cache/*.xb; \\
    rm -f /var/lib/authselect/checksum
COPY tmpfiles/ /usr/lib/tmpfiles.d/
COPY requires /requires
COPY check-requires.sh /check-requires.sh
PROBE

        echo
        echo "== probe: building the smallest image that can answer (one dnf transaction)"
        if ! privc podman build -q -t localhost/alloy-preflight-probe:"$HOST" "$probe" >/dev/null 2>"$probe/build.err"; then
            echo "   SKIP the probe would not build:"
            sed 's/^/        /' "$probe/build.err" | tail -6
            rc=1
        else
            # Packages the base repos cannot see. The real build enables Terra
            # and several COPRs before it installs; the probe does not, so those
            # are absent here and their absence is the probe's blind spot rather
            # than a finding. Named, so nobody reads a clean probe as covering
            # more than it does. --skip-unavailable is what keeps one of them
            # from failing the whole transaction.
            missing="$(privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" -c \
                'for p in '"$pkgset"'; do rpm -q --whatprovides "$p" >/dev/null 2>&1 || echo "$p"; done' 2>/dev/null | tr '\n' ' ')"
            notprobed="$(sed -n 's/^NOT-PROBED: //p' "$out")"
            [ -n "$notprobed" ] && missing="$missing$notprobed"
            if [ -n "$missing" ]; then
                echo
                echo "== probe coverage: not installed here, so not checked"
                printf '   %s\n' "$missing" | fold -s -w 76 | sed 's/^/   /'
                echo "   (the real build enables Terra and COPRs first; the probe does not)"
            fi

            echo
            echo "== requires: what this host's role needs, asked of the image"
            set +e
            privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" /check-requires.sh
            [ $? -eq 0 ] || rc=1
            set -e

            echo
            echo "== var-payload: bootc's own lint, on that probe"
            lint="$(privc podman run --rm --entrypoint bootc localhost/alloy-preflight-probe:"$HOST" \
                     container lint --no-truncate 2>&1 || true)"
            if printf '%s' "$lint" | grep -q 'var-tmpfiles'; then
                printf '%s\n' "$lint" | sed -n '/var-tmpfiles/,/^$/p' | sed 's/^/   /'
                echo "   FAIL bootc would refuse this mint's /var; declare the paths above"
                rc=1
            else
                echo "   ok: bootc finds nothing undeclared in /var for this package set"
            fi
        fi
    fi
fi

# == modes ==================================================================
#
# Every script this image installs into /usr/bin has to be executable in the
# repo, because the COPY that places it preserves the mode git recorded. A 0644
# script is installed, present, readable, and silently never runs: the unit that
# calls it fails at exec time on a machine nobody is watching.
#
# Learned expensively on 2026-09-08. usr/bin/alloy-usb-notify and
# usr/bin/alloy-usb-seed were committed 0644, and the Containerfile's own
# assertion caught the first of them at STEP 84 of 104, roughly twenty minutes
# into a mint. That assertion is right to exist and stays; this check answers
# the same question in a millisecond, before anything is built.
#
# It is a medium finding rather than a host one: the mode is in the commit, so
# the image is wrong wherever it is built.
echo
echo "== modes: every /usr/bin script executable, as the COPY will place it"
modes_bad=0
while read -r mode _ _ path; do
    case "$mode" in
        100755) ;;
        *) printf '   FAIL %-52s mode %s; installed but never runs\n' "$path" "$mode"
           modes_bad=1 ;;
    esac
done <<EOF
$(git -C "$REPO_ROOT" ls-files -s usr/bin/ usr/libexec/ 2>/dev/null)
EOF
if [ "$modes_bad" -eq 0 ]; then
    echo "   ok: $(git -C "$REPO_ROOT" ls-files usr/bin/ usr/libexec/ 2>/dev/null | wc -l) scripts, all executable"
else
    rc=1
fi

# The guards run out here rather than in the container, because they read sibling
# repos under ~/Code that the container has no business mounting. They are plain
# greps and need no Fedora anything.
#
# This is the only check about regression rather than absence. NO_STRIP lived in
# _private/scripts/build-dist.sh, the move to Bento recipes dropped it, and a
# build rediscovered it months later: linuxdeploy bundles its own binutils and
# cannot read Fedora 43's `.relr.dyn` sections, so without it every AppImage
# built on an Alloy host fails at bundling with an error naming nothing useful.
echo
echo "== guards: known workarounds, asserted to still be there"
tree_root="$(cd "$REPO_ROOT/.." && pwd)"
guards_bad=0
for rel in Apps/goingson/dist/recipes/linux.rhai Apps/balanced_breakfast/dist/recipes/linux.rhai; do
    f="$tree_root/$rel"
    if [ ! -f "$f" ]; then
        printf '   SKIP %-52s not on this machine\n' "$rel"
    elif grep -q 'NO_STRIP' "$f"; then
        printf '   ok   %-52s carries NO_STRIP\n' "$rel"
    else
        printf '   FAIL %-52s lost NO_STRIP; every AppImage built on Alloy would fail at bundling\n' "$rel"
        guards_bad=1
    fi
done

# Two kinds of finding, and they are not the same kind of problem, so they no
# longer share an exit code.
#
# dials, requires and var-payload are about the medium: an image built with one
# of them outstanding is wrong wherever it is built, and a mint should not
# proceed. guards is about the machine you are standing on -- it reads sibling
# repos under ~/Code that no image contains -- so a stale goingson clone predicts
# a failed AppImage on this host and says nothing about the medium being minted.
#
# Collapsed into one code, the script could not be used as a gate at all: it
# would refuse to build a perfectly good ISO because a repo next door was
# behind. Separated, a caller can act on each. build/preflight-gate.sh does.
if [ "$rc" -eq 0 ] && [ "$guards_bad" -eq 0 ]; then
    echo; echo "preflight: clean"
    exit 0
fi
if [ "$rc" -eq 0 ]; then
    echo; echo "preflight: this host has findings; the medium itself is clean"
    exit 4
fi
echo; echo "preflight: findings above; fix them before building"
exit 1
