#!/usr/bin/env bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
    echo "run this script as root" >&2
    exit 1
fi
PG=${1:-}
case "$PG" in
    14|15|16|17|18) ;;
    *) echo "usage: sudo test/ci.sh <14|15|16|17|18>" >&2; exit 2 ;;
esac
repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
temp=$(mktemp -d)
chmod 0755 "$temp"
cluster_data="/var/lib/postgresql/$PG/ci"
token_file="$cluster_data/pglc.token"
pg_bindir="/usr/lib/postgresql/$PG/bin"
pg_config="$pg_bindir/pg_config"
psql="$pg_bindir/psql"
cluster_created=0
cleanup() {
    local status=$?
    if (( cluster_created )); then
        pg_ctlcluster "$PG" ci stop >/dev/null 2>&1 || true
    fi
    rm -rf -- "$temp"
    exit "$status"
}
trap cleanup EXIT
retry_apt() {
    local attempt
    for attempt in 1 2 3 4 5; do
        if apt-get update && apt-get install -y "$@"; then
            return 0
        fi
        echo "apt attempt $attempt/5 failed" >&2
        sleep $((attempt * 4))
    done
    return 1
}
retry_apt postgresql-common
for attempt in 1 2 3 4 5; do
    if /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y; then
        break
    fi
    [[ "$attempt" -lt 5 ]] || exit 1
    echo "PGDG repository setup attempt $attempt/5 failed" >&2
    sleep $((attempt * 4))
done
retry_apt "postgresql-$PG" "postgresql-server-dev-$PG" build-essential \
    python3 git openssl libssl-dev
# Runs as root over a checkout owned by another user (sudo on CI runners).
git config --global --add safe.directory "$repo"
# Build an isolated working copy so PGXS artifacts do not dirty the checkout.
mkdir "$temp/current"
# Copy tracked and untracked-but-not-ignored files only: stale ignored objects
# (src/*.o) would otherwise be linked instead of rebuilt.
git -C "$repo" ls-files -z --cached --others --exclude-standard \
    | (cd "$repo" && while IFS= read -r -d '' path; do
        if [[ -e $path ]]; then printf '%s\0' "$path"; fi
    done | xargs -0 tar -cf - --) | tar -C "$temp/current" -xf -
make -C "$temp/current" PG_CONFIG="$pg_config" COPT=-Werror
make -C "$temp/current" PG_CONFIG="$pg_config" install
[[ ! -e "/etc/postgresql/$PG/ci" ]] || {
    echo "cluster $PG/ci already exists" >&2
    exit 1
}
cluster_created=1
pg_createcluster "$PG" ci -p 5433 --start-conf=manual
cat >/etc/postgresql/"$PG"/ci/pg_hba.conf <<'HBA'
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
HBA
pg_ctlcluster "$PG" ci start
runuser -u postgres -- "$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d postgres <<'SQL'
CREATE ROLE local_cache_worker LOGIN NOINHERIT NOSUPERUSER
    NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE ROLE local_cache_test_app LOGIN PASSWORD 'ci_test_password_123456'
    NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
CREATE ROLE local_cache_test_monitor NOLOGIN;
GRANT CONNECT ON DATABASE postgres TO local_cache_worker, local_cache_test_app;
GRANT USAGE ON SCHEMA public TO local_cache_test_app;
SQL
pg_ctlcluster "$PG" ci stop
install -o postgres -g postgres -m 0600 /dev/null "$token_file"
auth_token=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
printf '%s\n' "$auth_token" >"$token_file"
cat >>/etc/postgresql/"$PG"/ci/postgresql.conf <<'CONF'
include_if_exists = 'pglc.conf'
CONF
cat >/etc/postgresql/"$PG"/ci/pglc.conf <<'CONF'
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'contrib_regression'
pg_local_cache.role = 'regress_pglc_worker'
pg_local_cache.port = 0
CONF
pg_ctlcluster "$PG" ci start
chown -R postgres:postgres "$temp/current"
if ! runuser -u postgres -- env PGPORT=5433 PGHOST=127.0.0.1 PGUSER=postgres \
    make -C "$temp/current" PG_CONFIG="$pg_config" installcheck; then
    [[ ! -f "$temp/current/regression.diffs" ]] || cat "$temp/current/regression.diffs"
    exit 1
fi
cat >/etc/postgresql/"$PG"/ci/pglc.conf <<CONF
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'postgres'
pg_local_cache.role = 'local_cache_worker'
pg_local_cache.port = 6390
pg_local_cache.bind_address = '127.0.0.1'
pg_local_cache.auth_token_file = '$token_file'
pg_local_cache.workers = 1
pg_local_cache.cache_entries = 512
pg_local_cache.max_clients = 16
pg_local_cache.max_clients_per_worker = 16
pg_local_cache.memory_budget_mb = 64
CONF
pg_ctlcluster "$PG" ci restart
runuser -u postgres -- env PGPORT=5433 PGHOST=127.0.0.1 PGUSER=postgres \
    "$psql" -X -v ON_ERROR_STOP=1 -d postgres -c \
    'CREATE EXTENSION IF NOT EXISTS pg_local_cache; GRANT USAGE ON SCHEMA local_cache TO local_cache_worker; GRANT SELECT ON local_cache.mapping TO local_cache_worker;'

export PG_LOCAL_CACHE_PSQL="$psql" PGHOST=127.0.0.1 PGPORT=5433
export PGDATABASE=postgres PGUSER=postgres
export PG_LOCAL_CACHE_RESP_HOST=127.0.0.1 PG_LOCAL_CACHE_RESP_PORT=6390
export PG_LOCAL_CACHE_AUTH_TOKEN="$auth_token"
export PG_LOCAL_CACHE_TEST_ROLE=local_cache_worker
export PG_LOCAL_CACHE_TEST_APP_ROLE=local_cache_test_app
export PG_LOCAL_CACHE_TEST_APP_PASSWORD=ci_test_password_123456
export PG_LOCAL_CACHE_TEST_APP_HOST=127.0.0.1
for integration in whole_row_integration pipeline_integration \
    oom_monitoring_integration; do
    echo "==> $integration"
    python3 "$repo/tests/$integration.py"
done
echo "==> stress_integration over plaintext RESP"
python3 "$repo/tests/stress_integration.py"

# Exercise worker fan-out with a bounded run, then restore the normal CI config.
sed -i 's/pg_local_cache.workers = 1/pg_local_cache.workers = 2/' \
    /etc/postgresql/"$PG"/ci/pglc.conf
pg_ctlcluster "$PG" ci restart
echo "==> stress_integration with two workers"
stress_status=0
if PGLC_STRESS_SECONDS=10 python3 "$repo/tests/stress_integration.py"; then
    stress_status=0
else
    stress_status=$?
fi
sed -i 's/pg_local_cache.workers = 2/pg_local_cache.workers = 1/' \
    /etc/postgresql/"$PG"/ci/pglc.conf
pg_ctlcluster "$PG" ci restart
if (( stress_status != 0 )); then
    exit "$stress_status"
fi

# Keep TLS fixtures inside the cluster data directory so PostgreSQL can read
# them with relative paths and tests never depend on a host certificate store.
tls_dir="$cluster_data/tls"
install -d -o postgres -g postgres -m 0700 "$tls_dir"
old_umask=$(umask)
umask 077
openssl genrsa -out "$tls_dir/ca.key" 2048
openssl req -x509 -new -key "$tls_dir/ca.key" -sha256 -days 2 \
    -subj '/CN=pg_local_cache test CA' \
    -addext 'basicConstraints=critical,CA:TRUE' \
    -addext 'keyUsage=critical,keyCertSign,cRLSign' \
    -out "$tls_dir/ca.crt"
openssl genrsa -out "$tls_dir/server.key" 2048
openssl req -new -key "$tls_dir/server.key" -subj '/CN=127.0.0.1' \
    -out "$tls_dir/server.csr"
cat >"$tls_dir/server.ext" <<'EXT'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=IP:127.0.0.1
EXT
openssl x509 -req -in "$tls_dir/server.csr" -CA "$tls_dir/ca.crt" \
    -CAkey "$tls_dir/ca.key" -CAcreateserial -days 2 -sha256 \
    -extfile "$tls_dir/server.ext" -out "$tls_dir/server.crt"
openssl genrsa -out "$tls_dir/client.key" 2048
openssl req -new -key "$tls_dir/client.key" -subj '/CN=pg_local_cache test client' \
    -out "$tls_dir/client.csr"
cat >"$tls_dir/client.ext" <<'EXT'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
EXT
openssl x509 -req -in "$tls_dir/client.csr" -CA "$tls_dir/ca.crt" \
    -CAkey "$tls_dir/ca.key" -CAcreateserial -days 2 -sha256 \
    -extfile "$tls_dir/client.ext" -out "$tls_dir/client.crt"
umask "$old_umask"
chown -R postgres:postgres "$tls_dir"
chmod 0600 "$tls_dir/ca.key" "$tls_dir/server.key" "$tls_dir/client.key"
chmod 0644 "$tls_dir/ca.crt" "$tls_dir/server.crt" "$tls_dir/client.crt"

cat >/etc/postgresql/"$PG"/ci/pglc.conf <<CONF
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'postgres'
pg_local_cache.role = 'local_cache_worker'
pg_local_cache.port = 6391
pg_local_cache.bind_address = '127.0.0.1'
pg_local_cache.auth_token_file = '$token_file'
pg_local_cache.workers = 1
pg_local_cache.cache_entries = 512
pg_local_cache.max_clients = 16
pg_local_cache.max_clients_per_worker = 16
pg_local_cache.memory_budget_mb = 64
pg_local_cache.idle_timeout_ms = 1000
pg_local_cache.tls = on
pg_local_cache.tls_cert_file = 'tls/server.crt'
pg_local_cache.tls_key_file = 'tls/server.key'
CONF
pg_ctlcluster "$PG" ci restart
echo "==> TLS handshake deadline"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/tls_integration.py" --handshake-timeout
sed -i 's/pg_local_cache.idle_timeout_ms = 1000/pg_local_cache.idle_timeout_ms = 60000/' \
    /etc/postgresql/"$PG"/ci/pglc.conf
pg_ctlcluster "$PG" ci restart
echo "==> tls_integration"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/tls_integration.py"
echo "==> pipeline_integration over TLS"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/pipeline_integration.py"
echo "==> stress_integration over TLS"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    PGLC_STRESS_SECONDS=10 python3 "$repo/tests/stress_integration.py"

cat >>/etc/postgresql/"$PG"/ci/pglc.conf <<'CONF'
pg_local_cache.tls_min_protocol_version = 'TLSv1.3'
CONF
pg_ctlcluster "$PG" ci restart
echo "==> TLS protocol floor"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/tls_integration.py" --protocol-floor

cat >/etc/postgresql/"$PG"/ci/pglc.conf <<CONF
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'postgres'
pg_local_cache.role = 'local_cache_worker'
pg_local_cache.port = 6391
pg_local_cache.bind_address = '0.0.0.0'
pg_local_cache.auth_token_file = '$token_file'
pg_local_cache.workers = 1
pg_local_cache.cache_entries = 512
pg_local_cache.max_clients = 16
pg_local_cache.max_clients_per_worker = 16
pg_local_cache.memory_budget_mb = 64
pg_local_cache.idle_timeout_ms = 60000
pg_local_cache.tls = on
pg_local_cache.tls_cert_file = 'tls/server.crt'
pg_local_cache.tls_key_file = 'tls/server.key'
CONF
pg_ctlcluster "$PG" ci restart
echo "==> TLS non-loopback health"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/tls_integration.py" --bind-health

cat >/etc/postgresql/"$PG"/ci/pglc.conf <<CONF
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'postgres'
pg_local_cache.role = 'local_cache_worker'
pg_local_cache.port = 6391
pg_local_cache.bind_address = '127.0.0.1'
pg_local_cache.auth_token_file = '$token_file'
pg_local_cache.workers = 1
pg_local_cache.cache_entries = 512
pg_local_cache.max_clients = 16
pg_local_cache.max_clients_per_worker = 16
pg_local_cache.memory_budget_mb = 64
pg_local_cache.idle_timeout_ms = 60000
pg_local_cache.tls = on
pg_local_cache.tls_cert_file = 'tls/server.crt'
pg_local_cache.tls_key_file = 'tls/server.key'
pg_local_cache.tls_ca_file = 'tls/ca.crt'
CONF
pg_ctlcluster "$PG" ci restart
echo "==> mTLS integration"
PG_LOCAL_CACHE_TLS_CA="$tls_dir/ca.crt" \
    PG_LOCAL_CACHE_TLS_CERT="$tls_dir/client.crt" \
    PG_LOCAL_CACHE_TLS_KEY="$tls_dir/client.key" \
    PG_LOCAL_CACHE_TLS_WRONG_CERT="$tls_dir/server.crt" \
    PG_LOCAL_CACHE_TLS_WRONG_KEY="$tls_dir/server.key" \
    PG_LOCAL_CACHE_RESP_PORT=6391 \
    python3 "$repo/tests/tls_integration.py" --mtls

default_version=$(sed -n "s/^default_version = '\([^']*\)'$/\1/p" "$repo/pg_local_cache.control")
mapfile -t sorted_versions < <(
    {
        git -C "$repo" tag --list 'v*' | sed -n 's/^v//p'
        printf '%s\n' "$default_version"
    } | sort -V
)
upgrade_tag=
for version in "${sorted_versions[@]}"; do
    [[ "$version" != "$default_version" ]] || break
    upgrade_tag="v$version"
done
[[ -n "$upgrade_tag" ]] || {
    echo "no v* tag sorts below default_version $default_version for upgrade test" >&2
    exit 1
}
echo "==> upgrade from $upgrade_tag to $default_version"
mkdir "$temp/old"
git -C "$repo" archive "$upgrade_tag" | tar -C "$temp/old" -xf -
# Old releases refuse to build from an archive without an explicit build id.
make -C "$temp/old" PG_CONFIG="$pg_config" PGLC_BUILD_ID="$upgrade_tag" COPT=-Werror
make -C "$temp/old" PG_CONFIG="$pg_config" PGLC_BUILD_ID="$upgrade_tag" install
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d postgres -c 'CREATE DATABASE upgrade_old;'
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d postgres -c 'CREATE DATABASE upgrade_new;'
cat >/etc/postgresql/"$PG"/ci/pglc.conf <<CONF
shared_preload_libraries = 'pg_local_cache'
pg_local_cache.database = 'upgrade_old'
pg_local_cache.role = 'local_cache_worker'
pg_local_cache.port = 6390
pg_local_cache.bind_address = '127.0.0.1'
pg_local_cache.auth_token_file = '$token_file'
pg_local_cache.workers = 1
pg_local_cache.cache_entries = 512
pg_local_cache.max_clients = 16
pg_local_cache.max_clients_per_worker = 16
pg_local_cache.memory_budget_mb = 64
CONF
pg_ctlcluster "$PG" ci restart
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old <<'SQL'
CREATE EXTENSION pg_local_cache;
GRANT CONNECT ON DATABASE upgrade_old TO local_cache_worker;
GRANT USAGE ON SCHEMA local_cache TO local_cache_worker;
GRANT SELECT ON local_cache.mapping TO local_cache_worker;
CREATE TABLE public.upgrade_attached (id bigint PRIMARY KEY, value text);
INSERT INTO public.upgrade_attached VALUES (1, 'before-upgrade');
GRANT SELECT, INSERT, UPDATE, DELETE ON public.upgrade_attached TO local_cache_worker;
SELECT local_cache.attach_table('public.upgrade_attached'::regclass);
GRANT EXECUTE ON FUNCTION local_cache.invalidate(text) TO local_cache_test_app;
GRANT EXECUTE ON FUNCTION local_cache.metrics() TO local_cache_test_monitor;
CREATE VIEW public.upgrade_health_view AS SELECT local_cache.health() AS payload;
CREATE VIEW public.upgrade_metrics_dependency AS SELECT * FROM local_cache.metrics();
SQL

# Fill an attached key while the old library is loaded, then replace the
# library and warm it again before the catalog migration.
PG_LOCAL_CACHE_PSQL="$psql" PGPORT=5433 PGHOST=127.0.0.1 \
    PGDATABASE=upgrade_old PGUSER=postgres \
    PG_LOCAL_CACHE_RESP_HOST=127.0.0.1 PG_LOCAL_CACHE_RESP_PORT=6390 \
    PG_LOCAL_CACHE_AUTH_TOKEN="$auth_token" python3 - "$repo" <<'PY'
import sys
sys.path.insert(0, sys.argv[1] + "/tests")
from pipeline_integration import RespConnection, crud_key, wait_for_mapping

client = RespConnection()
try:
    value = wait_for_mapping(client, crud_key("upgrade_attached", 1))
    assert value == b'{"id":1,"value":"before-upgrade"}', value
finally:
    client.close()
PY
make -C "$temp/current" PG_CONFIG="$pg_config" install
pg_ctlcluster "$PG" ci restart
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_new <<'SQL'
CREATE EXTENSION pg_local_cache;
-- Same operator grants as upgrade_old, so the snapshots differ only by migration drift.
GRANT USAGE ON SCHEMA local_cache TO local_cache_worker;
GRANT SELECT ON local_cache.mapping TO local_cache_worker;
GRANT EXECUTE ON FUNCTION local_cache.invalidate(text) TO local_cache_test_app;
GRANT EXECUTE ON FUNCTION local_cache.metrics() TO local_cache_test_monitor;
SQL
PG_LOCAL_CACHE_PSQL="$psql" PGPORT=5433 PGHOST=127.0.0.1 \
    PGDATABASE=upgrade_old PGUSER=postgres \
    PG_LOCAL_CACHE_RESP_HOST=127.0.0.1 PG_LOCAL_CACHE_RESP_PORT=6390 \
    PG_LOCAL_CACHE_AUTH_TOKEN="$auth_token" python3 - "$repo" <<'PY'
import sys
sys.path.insert(0, sys.argv[1] + "/tests")
from pipeline_integration import RespConnection, crud_key, wait_for_mapping

client = RespConnection()
try:
    value = wait_for_mapping(client, crud_key("upgrade_attached", 1))
    assert value == b'{"id":1,"value":"before-upgrade"}', value
finally:
    client.close()
PY
if ! "$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old \
    -c 'ALTER EXTENSION pg_local_cache UPDATE;' >"$temp/dependency-error.log" 2>&1; then
    grep -Eq 'cannot drop function (local_cache\.)?_metrics_2_0_4\(\) because other objects depend on it' \
        "$temp/dependency-error.log" || {
        cat "$temp/dependency-error.log" >&2
        echo "upgrade failed for an unexpected dependency" >&2
        exit 1
    }
else
    echo "upgrade unexpectedly dropped metrics() despite a dependent user view" >&2
    exit 1
fi
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old -c \
    'DROP VIEW public.upgrade_metrics_dependency;
     ALTER EXTENSION pg_local_cache UPDATE;'
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old -c \
    "DO \$\$ BEGIN
        IF NOT pg_catalog.has_function_privilege(
            'local_cache_test_app', 'local_cache.invalidate(text)', 'EXECUTE') THEN
            RAISE EXCEPTION 'custom extension function grant was lost';
        END IF;
        IF NOT pg_catalog.has_function_privilege(
            'local_cache_test_monitor', 'local_cache.metrics()', 'EXECUTE') THEN
            RAISE EXCEPTION 'custom metrics() grant was lost';
        END IF;
        IF NOT EXISTS (
            SELECT 1 FROM pg_catalog.pg_trigger
             WHERE tgrelid = 'public.upgrade_attached'::regclass
               AND tgname IN ('pg_local_cache_statement_guard',
                              'pg_local_cache_row_invalidate',
                              'pg_local_cache_truncate_invalidate')
             GROUP BY tgrelid HAVING count(*) = 3
        ) THEN
            RAISE EXCEPTION 'attached-table triggers were lost';
        END IF;
        IF NOT EXISTS (SELECT 1 FROM public.upgrade_health_view
                       WHERE payload ? 'ready') THEN
            RAISE EXCEPTION 'dependent health view stopped working';
        END IF;
        IF current_setting('pg_local_cache.binary_version') <> '$default_version' THEN
            RAISE EXCEPTION 'loaded binary version is %',
                current_setting('pg_local_cache.binary_version');
        END IF;
     END \$\$;"
before_invalidations=$("$psql" -X -qAt -v ON_ERROR_STOP=1 -p 5433 \
    -d upgrade_old -c "SELECT (local_cache.stats() ->> 'invalidations')::bigint")
"$psql" -X -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old -c \
    "UPDATE public.upgrade_attached SET value = 'after-upgrade' WHERE id = 1;"
after_invalidations=$("$psql" -X -qAt -v ON_ERROR_STOP=1 -p 5433 \
    -d upgrade_old -c "SELECT (local_cache.stats() ->> 'invalidations')::bigint")
(( after_invalidations > before_invalidations )) || {
    echo "attached-table UPDATE did not increment invalidations" >&2
    exit 1
}
"$psql" -X -qAt -v ON_ERROR_STOP=1 -p 5433 -d upgrade_old \
    -f "$temp/current/test/extension_snapshot.sql" >"$temp/upgrade_old.snapshot"
"$psql" -X -qAt -v ON_ERROR_STOP=1 -p 5433 -d upgrade_new \
    -f "$temp/current/test/extension_snapshot.sql" >"$temp/new.snapshot"
diff -u "$temp/upgrade_old.snapshot" "$temp/new.snapshot"
echo "all PostgreSQL $PG regression, RESP, and upgrade checks passed"
