#!/usr/bin/env bash
# DBSheriff bootstrap installer — served at https://get.dbsheriff.ai
#
# This script runs as root and decides what else runs as root, so verify it before you run it
# (customer_docs/verify-the-installer.md; two steps, the script is signed, Sigstore keyless):
#
#   curl -fsSLO https://get.dbsheriff.ai/install-vX.Y.Z.sh
#   curl -fsSLO https://get.dbsheriff.ai/install-vX.Y.Z.sh.sigstore.json
#   cosign verify-blob --bundle install-vX.Y.Z.sh.sigstore.json \
#     --certificate-identity https://github.com/dbsheriffai/dbsheriff/.github/workflows/release.yml@refs/tags/vX.Y.Z \
#     --certificate-oidc-issuer https://token.actions.githubusercontent.com install-vX.Y.Z.sh
#   sudo bash install-vX.Y.Z.sh --version vX.Y.Z --token '<download token>'
#
# The one-line form, for an evaluation on a host you can throw away, runs it WITHOUT checking it:
#
#   curl -fsSL https://get.dbsheriff.ai | sudo bash -s -- --version v1.2.0 --token '<download token>'
#
# Downloads the versioned install package from releases.dbsheriff.ai, checks it against SHA256SUMS
# (whose Sigstore signature is verified first: no signature that verifies, no install), unpacks it
# under /opt/dbsheriff/releases/<version>, points /opt/dbsheriff/current at it and hands over to the
# target's guided installer. Running it again with a newer --version is the upgrade path.
#
# The signature check needs cosign. The host's own cosign is used when there is one; otherwise the
# cosign image pinned by digest below runs through Docker (Docker is a prerequisite of the product
# anyway). With neither, the installer stops: say --skip-signature to go on without the check, and the
# only thing left that vouches for the download is a checksum served from the same place as the file.
#
# Options
#   --version vX.Y.Z    release to install (required)
#   --token   '...'     download token from your DBSheriff release e-mail
#                       (or env DBSHERIFF_DOWNLOAD_TOKEN); it is the signed
#                       query string that unlocks releases/<version>/*. It is handed to
#                       curl through its standard input, never on a command line
#   --target  onprem    deployment overlay: onprem (default) | aws
#   --dir     PATH      install root (default /opt/dbsheriff)
#   --package customer  the install package (the only one distributed; the default)
#   --base-url URL      where packages live (default https://releases.dbsheriff.ai); https only
#   --insecure-base-url allow an http:// or file:// --base-url (a lab or a test; the token then
#                       travels in clear or is read from disk)
#   --no-install        download, verify and unpack only
#   --skip-signature    do not verify any Sigstore signature (loud; see above)
#   --trusted-root FILE verify signatures with this Sigstore trusted root instead of fetching one from
#                       Sigstore's TUF repository: for a host that cannot reach it (an air-gapped one).
#                       The file must be the one pinned below (https://get.dbsheriff.ai/trusted_root.json;
#                       its sha256 is on the verification page), or one whose sha256 you give with
#                       --trusted-root-sha256 and so vouch for yourself
#   --trusted-root-sha256 HEX  the sha256 of the --trusted-root file you supply
#   --airgap            also download the release's image bundle (~8 GB, every
#                       image the stack runs) with the same token, verify its signed
#                       checksum and its signed image manifest (required: the installer
#                       checks every loaded image against it) and load the images from
#                       it — no registry credentials needed (what trial e-mails use)
#   -- ARGS...          everything after -- goes to deploy/<target>/install.sh
#                       (e.g. -- --yes --license /root/acme.lic --engines postgresql,mysql)
#
# Env: DBSHERIFF_COSIGN=host|container|auto (default auto) chooses where cosign comes from.
#
# Shape of this file (S-13b, LICE-24): every command that does anything is inside a function and the
# last line calls it, so a download cut short anywhere runs nothing: a script that ends before `main`
# never calls it, and one cut inside a function does not parse.
set -euo pipefail

# ── the trust anchor: what this script believes, written here so it can be read and compared ────────
# The signer of a release is its release workflow at its tag, in this repository (keyless Sigstore:
# the certificate names the workflow file at the tag, issued by GitHub's OIDC provider). Matched exactly,
# workflow AND tag, so another release's signed SHA256SUMS cannot be replayed under this version.
# customer_docs/verify-the-installer.md prints the same values; tests/test_bootstrap_installer.py checks
# that the two agree with each other and with deploy/common/host/verify_images.sh.
readonly SIGNER_REPO="dbsheriffai/dbsheriff"
readonly SIGNER_IDENTITY_REGEXP='^https://github\.com/dbsheriffai/dbsheriff/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'
readonly OIDC_ISSUER="https://token.actions.githubusercontent.com"
# The cosign that runs when the host has none: this image, by digest (the same pin as deploy/common/host/verify_images.sh).
readonly COSIGN_IMAGE="ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8"
# release/bootstrap/trusted_root.json, the Sigstore public-good trusted root, published at https://get.dbsheriff.ai/trusted_root.json.
# Refreshed with release/bootstrap/refresh_trusted_root.sh (Sigstore rotates it about yearly); a stale root fails closed.
readonly PINNED_TRUSTED_ROOT_SHA256="6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66"

say()  { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }

usage() { sed -n '2,/^set -euo/p' "$1" | sed -e '/^set -euo/d' -e 's/^# \{0,1\}//'; }

# Who signed this release. Releases up to v1.2.2 were published while the repository lived under its earlier
# GitHub account, and their Sigstore certificates (Rekor entries are immutable) name that path (LEGACY): every later
# release is signed by $SIGNER_REPO. Matched exactly. The earlier-account branch stays until those releases
# are retired from the delivery bucket (planned for v1.4.0); then delete it, the owner variable and the v1.1.0/v1.2.2
# cases of tests/test_bootstrap_installer.py.
signer_identity() {  # signer_identity <version>
  local ver="$1" v a b c id
  v="${ver#v}"
  a="${v%%.*}"; v="${v#*.}"; b="${v%%.*}"; c="${v#*.}"
  a="${a%%[!0-9]*}"; b="${b%%[!0-9]*}"; c="${c%%[!0-9]*}"     # v1.3.0-rc1 -> 1 3 0
  if [ $(( 10#${a:-0} * 1000000 + 10#${b:-0} * 1000 + 10#${c:-0} )) -le 1002002 ]; then
    id="https://github.com/sduggira/dbsheriff/.github/workflows/release.yml@refs/tags/$ver"
  else
    id="https://github.com/$SIGNER_REPO/.github/workflows/release.yml@refs/tags/$ver"
    [[ "$id" =~ $SIGNER_IDENTITY_REGEXP ]] || die "internal error: the identity for $ver does not match the pinned pattern"
  fi
  echo "$id"
}

main() {
  local VERSION="" TOKEN="${DBSHERIFF_DOWNLOAD_TOKEN:-}" TARGET="onprem" DIR="/opt/dbsheriff"
  local FLAVOUR="customer" BASE_URL="${DBSHERIFF_RELEASES_URL:-https://releases.dbsheriff.ai}"
  local DO_INSTALL=1 VERIFY_SIG=1 AIRGAP=0 INSECURE_URL=0 TRUSTED_ROOT="" TRUSTED_ROOT_SHA256=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --version) [ $# -ge 2 ] || die "--version needs a value"; VERSION="$2"; shift 2 ;;
      --token) [ $# -ge 2 ] || die "--token needs a value"; TOKEN="$2"; shift 2 ;;
      --target) [ $# -ge 2 ] || die "--target needs a value"; TARGET="$2"; shift 2 ;;
      --dir) [ $# -ge 2 ] || die "--dir needs a value"; DIR="$2"; shift 2 ;;
      --package) [ $# -ge 2 ] || die "--package needs a value"; FLAVOUR="$2"; shift 2 ;;
      --base-url) [ $# -ge 2 ] || die "--base-url needs a value"; BASE_URL="${2%/}"; shift 2 ;;
      --insecure-base-url) INSECURE_URL=1; shift ;;
      --no-install) DO_INSTALL=0; shift ;;
      --skip-signature) VERIFY_SIG=0; shift ;;
      --trusted-root) [ $# -ge 2 ] || die "--trusted-root needs a file"; TRUSTED_ROOT="$2"; shift 2 ;;
      --trusted-root-sha256) [ $# -ge 2 ] || die "--trusted-root-sha256 needs a value"; TRUSTED_ROOT_SHA256="$2"; shift 2 ;;
      --airgap) AIRGAP=1; shift ;;
      -h|--help) usage "$0"; exit 0 ;;
      --) shift; break ;;
      *) echo "unknown option: $1" >&2; exit 2 ;;
    esac
  done
  local INSTALLER_ARGS=("$@")

  [ -n "$VERSION" ] || die "--version vX.Y.Z is required"
  # anchored, no glob: v1.2.3 and v1.2.3-rc1 only (the old pattern v[0-9]*.[0-9]*.[0-9]* also took v1/x.y.z and trailing text)
  [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]] || die "version must look like v1.2.0 (got $VERSION)"
  case "$TARGET" in onprem|aws) ;; *) die "--target must be onprem or aws";; esac
  case "$FLAVOUR" in
    customer) ;;
    full) die "--package full is not offered: only the customer package is distributed (it carries the guided installer). Drop --package, or pass --package customer";;
    *) die "--package must be customer (got $FLAVOUR)";;
  esac
  # the token and the URL go into a curl config file read from stdin: neither may hold a quote, a backslash or a space
  [[ "$BASE_URL" =~ ^[A-Za-z0-9+.:/_@%~-]+$ ]] || die "--base-url has characters a URL does not need (quotes, spaces, backslashes)"
  case "$BASE_URL" in
    https://*) ;;
    http://*|file://*) [ "$INSECURE_URL" = 1 ] || die "--base-url must be https (got ${BASE_URL%%://*}://...): a download token over http travels in clear. --insecure-base-url allows it for a lab" ;;
    *) die "--base-url must be an https URL" ;;
  esac
  [[ "$TOKEN" =~ ^[?]?[A-Za-z0-9_~=\&%.:/+*-]*$ ]] || die "the download token has characters a token never has (quotes, spaces, backslashes): copy it again from your e-mail"
  for t in curl tar; do command -v "$t" >/dev/null || die "$t is required"; done

  local SIGNER_ID; SIGNER_ID=$(signer_identity "$VERSION")
  local SHA
  if command -v sha256sum >/dev/null; then SHA="sha256sum"; elif command -v shasum >/dev/null; then SHA="shasum -a 256"; else die "sha256sum or shasum is required"; fi
  [ "$(id -u)" = 0 ] || [ -w "$(dirname "$DIR")" ] || [ -w "$DIR" ] 2>/dev/null || die "cannot write to $DIR — run as root or pass --dir"

  local BARE="${VERSION#v}"
  local PKG="dbsheriff-${BARE}-${VERSION}-customer.tar.gz"
  local Q=""; [ -n "$TOKEN" ] && Q="?${TOKEN#\?}"
  url() { echo "$BASE_URL/$VERSION/$1$Q"; }

  WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT      # global on purpose: the EXIT trap runs after main has returned
  # The URL, token included, goes to curl as a config on its standard input: it is never in argv, where every user of the host
  # can read it in `ps` for the whole 8 GB download.
  curl_get() {  # curl_get <url> <output file> [extra curl flags...]
    local u="$1" out="$2"; shift 2
    printf 'url = "%s"\n' "$u" | curl -K - -o "$out" "$@"
  }
  fetch() {  # fetch <name> [optional]   (curl's own error text is dropped: ours says what to do)
    if ! curl_get "$(url "$1")" "$WORK/$1" -fsL --retry 3 2>/dev/null; then
      [ "${2:-}" = optional ] && return 1
      die "download failed: $BASE_URL/$VERSION/$1 (wrong version, or the download token is missing/expired)"
    fi
  }

  # ── where cosign comes from ───────────────────────────────────────────────────────────────────────────────
  local MODE="none"
  if [ "$VERIFY_SIG" = 1 ]; then
    local want="${DBSHERIFF_COSIGN:-auto}"
    if [ "$want" != container ] && command -v cosign >/dev/null; then MODE=host
    elif [ "$want" != host ] && command -v docker >/dev/null; then MODE=container
    else
      die "cannot verify the release signature: this host has no cosign and no docker to run the pinned cosign image. Install cosign (https://github.com/sigstore/cosign/releases) or Docker and run this again; or pass --skip-signature to install WITHOUT checking the signature, in which case the only check left is a checksum served from the same place as the package"
    fi
  else
    warn "SIGNATURE VERIFICATION SKIPPED (--skip-signature): nothing but a checksum from the same server vouches for what is about to be installed as root"
  fi
  local ROOT_ARGS=() ROOT_MOUNT=()
  if [ -n "$TRUSTED_ROOT" ]; then
    [ "$VERIFY_SIG" = 1 ] || die "--trusted-root is for signature verification, which --skip-signature turns off"
    [ -f "$TRUSTED_ROOT" ] || die "--trusted-root: no such file: $TRUSTED_ROOT"
    TRUSTED_ROOT="$(cd "$(dirname "$TRUSTED_ROOT")" && pwd)/$(basename "$TRUSTED_ROOT")"
    local have pin="${TRUSTED_ROOT_SHA256:-$PINNED_TRUSTED_ROOT_SHA256}"
    have=$($SHA "$TRUSTED_ROOT" | cut -d' ' -f1)
    if [ "$have" != "$pin" ]; then
      die "--trusted-root $TRUSTED_ROOT has sha256 $have, not the $pin this installer pins (https://get.dbsheriff.ai/trusted_root.json, and the verification page, name it). Download the current file, or pass --trusted-root-sha256 $have to vouch for the one you have"
    fi
    if [ "$MODE" = container ]; then ROOT_MOUNT=(-v "$TRUSTED_ROOT:/trusted_root.json:ro"); ROOT_ARGS=(--trusted-root /trusted_root.json); else ROOT_ARGS=(--trusted-root "$TRUSTED_ROOT"); fi
    say "signatures are checked against the pinned Sigstore trusted root ($have): no contact with Sigstore's TUF repository"
  elif [ -n "$TRUSTED_ROOT_SHA256" ]; then
    die "--trusted-root-sha256 without --trusted-root"
  fi

  # verify_blob <file in $WORK>: its Sigstore bundle is <file>.sigstore.json, published beside it. Fails closed: a bundle that is
  # missing, one that does not verify, a certificate for another workflow, tag or commit all stop the install.
  verify_blob() {
    [ "$VERIFY_SIG" = 1 ] || return 0
    local f="$1" out rc=0
    fetch "$f.sigstore.json" optional || die "$f.sigstore.json is not published for $VERSION, so $f cannot be verified — do not install this download (--skip-signature installs without the check)"
    if [ "$MODE" = host ]; then
      out=$(cosign verify-blob --bundle "$WORK/$f.sigstore.json" \
              --certificate-identity "$SIGNER_ID" --certificate-oidc-issuer "$OIDC_ISSUER" \
              ${ROOT_ARGS[@]+"${ROOT_ARGS[@]}"} "$WORK/$f" 2>&1) || rc=$?
    else
      # as the invoking user, a writable HOME for cosign's cache, the files mounted read-only
      out=$(docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$WORK:/w:ro" ${ROOT_MOUNT[@]+"${ROOT_MOUNT[@]}"} "$COSIGN_IMAGE" \
              verify-blob --bundle "/w/$f.sigstore.json" \
              --certificate-identity "$SIGNER_ID" --certificate-oidc-issuer "$OIDC_ISSUER" \
              ${ROOT_ARGS[@]+"${ROOT_ARGS[@]}"} "/w/$f" 2>&1) || rc=$?
      if [ "$rc" -ge 125 ]; then
        printf '%s\n' "$out" | tail -5 >&2
        die "could not run the cosign container ($COSIGN_IMAGE): start Docker and allow it to pull from ghcr.io (or install cosign, or give --trusted-root and a local image), then run this again"
      fi
    fi
    if [ "$rc" -ne 0 ]; then
      printf '%s\n' "$out" | tail -5 >&2
      die "$f signature does not verify — do not install this download"
    fi
    say "$f: signature verified (Sigstore, $SIGNER_ID)"
  }

  say "DBSheriff $VERSION ($FLAVOUR package, target $TARGET) -> $DIR"
  [ "$MODE" = none ] || say "verifying signatures with cosign ($MODE)"
  say "downloading SHA256SUMS"
  fetch SHA256SUMS
  verify_blob SHA256SUMS
  grep -q " $PKG\$" "$WORK/SHA256SUMS" || die "$PKG is not part of release $VERSION"

  say "downloading $PKG"
  fetch "$PKG"
  ( cd "$WORK" && grep " $PKG\$" SHA256SUMS | $SHA -c - >/dev/null ) || die "checksum mismatch for $PKG"

  # Everything that can fail on the download is checked before the host changes: the package above, and the image bundle here.
  local IMG_DIR="" BUNDLE=""
  if [ "$AIRGAP" = 1 ]; then
    BUNDLE="dbsheriff-$VERSION-images.tar.gz"; IMG_DIR="$DIR/state/images"; mkdir -p "$IMG_DIR"
    say "downloading the image bundle $BUNDLE (about 8 GB: every image the stack runs) to $IMG_DIR"
    curl_get "$(url "$BUNDLE")" "$IMG_DIR/$BUNDLE" -fL --retry 3 || die "download failed: $BASE_URL/$VERSION/$BUNDLE (token missing/expired?)"
    fetch "$BUNDLE.sha256"
    # The bundle's checksum file and the image manifest (IMAGES-<tag>.txt: every
    # image with its registry digest and image id) are signed like SHA256SUMS.
    verify_blob "$BUNDLE.sha256"
    ( cd "$IMG_DIR" && sed "s#[^ ]*$BUNDLE#$BUNDLE#" "$WORK/$BUNDLE.sha256" | $SHA -c - >/dev/null ) || die "checksum mismatch for $BUNDLE"
    # The manifest is not optional: it is what the installer compares every loaded image's id with, so a bundle rebuilt with other
    # contents behind a matching name is caught. A release that does not publish it is not installed from a bundle (every release
    # since v1.2.2 does), and a download that cannot be had stops here instead of installing on the bundle's checksum alone (DEPS-05).
    fetch "IMAGES-$VERSION.txt" optional \
      || die "IMAGES-$VERSION.txt could not be downloaded from $BASE_URL/$VERSION/: without the signed image manifest the image bundle cannot be checked, so an air-gap install does not go on (an expired download token also stops here)"
    verify_blob "IMAGES-$VERSION.txt"
    cp "$WORK/IMAGES-$VERSION.txt" "$IMG_DIR/"     # the installer checks every loaded image's id against it
    say "image bundle verified; the installer will load it (delete $IMG_DIR afterwards to reclaim the space)"
    INSTALLER_ARGS=(--airgap "$IMG_DIR/$BUNDLE" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"})
  fi

  # Unpack beside the final place and move it in: the release directory is complete or absent, and `current` moves only
  # after every check above has passed (it used to move before the image bundle was verified, leaving root's cron and systemd
  # jobs pointing at a release whose download had just failed its check).
  local REL="$DIR/releases/$VERSION" STAGE
  mkdir -p "$DIR/releases" "$DIR/state"
  STAGE=$(mktemp -d "$DIR/releases/.$VERSION.partial.XXXXXX")
  say "unpacking to $REL"
  if ! tar --no-same-owner -xzf "$WORK/$PKG" -C "$STAGE"; then rm -rf "$STAGE"; die "could not unpack $PKG"; fi
  chmod 755 "$STAGE"
  if [ -e "$REL" ]; then rm -rf "$REL.replaced"; mv "$REL" "$REL.replaced"; fi
  mv "$STAGE" "$REL"
  rm -rf "$REL.replaced"
  ln -sfn "$REL" "$DIR/current"
  say "installed files: $DIR/current -> $REL"

  [ "$DO_INSTALL" = 1 ] || { say "done (--no-install). Next: cd $DIR/current && deploy/$TARGET/install.sh"; exit 0; }
  local INSTALLER="$DIR/current/deploy/$TARGET/install.sh"
  if [ -x "$INSTALLER" ] || [ -f "$INSTALLER" ]; then
    say "handing over to deploy/$TARGET/install.sh ${INSTALLER_ARGS[*]-}"
    cd "$DIR/current" && exec bash "deploy/$TARGET/install.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"}
  else
    say "no guided installer for target '$TARGET' in this release yet; see $DIR/current/deploy/$TARGET/README.md"
  fi
}

main "$@"
