From 54942f30f384ea268525e1b42f8100ebd655d268 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 26 Sep 2026 15:23:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(bootstrap):=20=E4=BB=8E=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E5=AE=89=E8=A3=85=E4=BA=8C=E8=BF=9B=E5=88=B6?= =?UTF-8?q?=E3=80=81=E9=95=9C=E5=83=8F=E4=B8=8E=E6=8F=92=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?Docker=20=E6=8C=89=E9=9C=80=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/bootstrap.sh | 749 ++++++++++++++++++++++++++++++++++----- deploy/bootstrap_test.sh | 568 ++++++++++++++++++++++++++++- 2 files changed, 1217 insertions(+), 100 deletions(-) diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 00e972b..2e4c4bb 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -71,13 +71,22 @@ # restarts cloudflared-felis onto the new binary (default: 0) # FELIS_REPO_URL git URL to build from (raw script mode only) # FELIS_VERSION_BOOTSTRAP release|dev — which version to install (default: release). -# release DOWNLOADS the prebuilt felis binary published for the newest -# tag (panel included — it is go:embed'ed into that same binary) and -# builds only a thin image around it; dev clones and compiles. If the -# asset is missing or this architecture has none, release warns and falls -# back to compiling the SAME tag. The downloaded binary must match -# the release's SHA256SUMS before it is run; a release without one -# is compiled from source too. The game stack is always built here. +# release DOWNLOADS what the newest tag publishes: the felis binary +# (panel included — it is go:embed'ed into that same binary), every +# image and the Velocity plugin, each checked against the release's +# SHA256SUMS before it is used, so the host needs neither Docker nor +# Docker Hub; dev clones and compiles. If an asset is missing or this +# architecture has none, release warns and builds that piece of the +# SAME tag here instead (with Docker); a release without SHA256SUMS +# is compiled from source whole. +# FELIS_ARTIFACT_DIR a directory holding a release's assets as release.yml publishes them +# (felis-linux-, felis-image-*-linux-.tar, their listing +# felis-images-linux-.txt, felis-velocity.jar, SHA256SUMS; see +# deploy/build-release-artifacts.sh): the binary, images and plugin are +# installed from there and nothing is fetched from api.github.com or +# Docker Hub, nor built (FELIS_GAME_STACK=latest aside: no release +# ships that stack, so its game images are built here). A file missing +# from it or not matching its SHA256SUMS stops the install. # FELIS_GITHUB_TOKEN GitHub token; REQUIRED while the repo is private # FELIS_REF branch/tag/sha — pins the build, overrides the channel, and forces a # source build (naming a ref asks for that tree, not a published asset) @@ -144,13 +153,43 @@ FELIS_REF="${FELIS_REF:-}" # built, so it takes the source path even on the release channel. FELIS_REF_PINNED="" if [ -n "$FELIS_REF" ]; then FELIS_REF_PINNED=1; fi -# Set once a prebuilt felis binary is installed at HOST_BIN, by either the TUI hand-off or a -# release download. It is what the image build, the CRD apply and the game stack key off: -# all three only need "is there a binary and no checkout", never "which route got us here". +# The directory of release assets to install from (header); empty installs as the channel says. +FELIS_ARTIFACT_DIR="${FELIS_ARTIFACT_DIR:-}" +# Set once a prebuilt felis binary is installed at HOST_BIN, by the TUI hand-off, a release +# download or FELIS_ARTIFACT_DIR. It is what the image build, the CRD apply and the game stack +# key off: all three only need "is there a binary and no checkout", never "which route got us +# here". HAVE_PREBUILT_BINARY="" # The image the control plane ran before this run moved it (deploy_bundle), for the rollback # hint in summary. PREVIOUS_FELIS_IMAGE="" +# Where the images and the Velocity plugin come from, decided by select_release_artifacts once +# the binary is on the host: "dir" (FELIS_ARTIFACT_DIR), "release" (the assets of release +# ARTIFACT_TAG, downloaded into ARTIFACT_CACHE), or empty, when everything is built here. +ARTIFACT_MODE="" +ARTIFACT_TAG="" +# Root-only, and outside /tmp: an image bundle waits here from its import to its push, and a +# run that failed in between finds it again. push_images_to_registry empties it. +ARTIFACT_CACHE="/var/lib/felis/artifacts" +# The SHA256SUMS every artifact is checked against (load_artifact_sums), and the verified local +# copy artifact_fetch last pointed at. +ARTIFACT_SUMS="" +ARTIFACT_FILE="" +# The validated lines of the image listing ("bundle role name manifest-digest config-digest"), +# and whether it was read yet: "", "ok", or "bad" (the listing could not be used). +RELEASE_LISTING="" +RELEASE_LISTING_STATE="" +# One "role bundle name target manifest-digest config-digest" line per image this run took from +# a bundle (import_release_images), and those roles; every other image is built here. +RELEASE_IMAGES="" +PREBUILT_ROLES=" " +# Docker is installed and started only for what has to be built on this host (ensure_docker). +DOCKER_INSTALLED="" +# The release JSON every asset lookup reads, fetched once per tag (load_release_json): an install +# downloads up to a dozen assets, and GitHub allows an address without a token 60 API calls an +# hour. +RELEASE_JSON_TAG="" +RELEASE_JSON="" # Optional GitHub credential, needed while this repository is private: GitHub answers # 404 (not 403) for a repo the caller cannot see, so without it both the release lookup # and the clone fail as "not found". Exported because git's credential helper below runs @@ -436,6 +475,8 @@ K3S_CONFIG_DROPIN="/etc/rancher/k3s/config.yaml.d/50-felis.yaml" K3S_UNIT_FILE="/etc/systemd/system/k3s.service" K3S_KUBECONFIG="/etc/rancher/k3s/k3s.yaml" K3S_KUBELET_CERT="/var/lib/rancher/k3s/agent/client-kubelet.crt" +# Where k3s imports image tarballs from as it starts (stage_k3s_airgap_images). +K3S_IMAGES_DIR="/var/lib/rancher/k3s/agent/images" # ensure_persistent_journal's drop-in, and the directory journald creates once it # stores the journal persistently. JOURNALD_DROPIN="/etc/systemd/journald.conf.d/50-felis.conf" @@ -830,6 +871,17 @@ validate_settings() { pinned|latest) ;; *) die "FELIS_GAME_STACK must be pinned or latest (got '${FELIS_GAME_STACK}')" ;; esac + if [ -n "$FELIS_ARTIFACT_DIR" ]; then + case "$FELIS_ARTIFACT_DIR" in + /*) ;; + *) die "FELIS_ARTIFACT_DIR must be an absolute path (got '${FELIS_ARTIFACT_DIR}')" ;; + esac + [ -f "${FELIS_ARTIFACT_DIR}/SHA256SUMS" ] \ + || die "FELIS_ARTIFACT_DIR: ${FELIS_ARTIFACT_DIR}/SHA256SUMS does not exist; point it at the directory deploy/build-release-artifacts.sh wrote, or at a release's downloaded assets" + # Each names what to install; the directory's binary would silently win. + [ -z "$FELIS_REF_PINNED" ] || die "FELIS_ARTIFACT_DIR and FELIS_REF both name what to install; set one" + [ -z "${FELIS_SKIP_FETCH:-}" ] || die "FELIS_ARTIFACT_DIR and FELIS_SKIP_FETCH both name what to install; set one" + fi [ "$(heap_megabytes "$FELIS_VELOCITY_XMX")" -ge 256 ] \ || die "FELIS_VELOCITY_XMX must be a heap size of at least 256M, written M or G (got '${FELIS_VELOCITY_XMX}')" case "$FELIS_UPGRADE_DEPS" in @@ -1080,16 +1132,38 @@ existing_ancestor() { # path_populated reports whether directory $1 exists with something in it. path_populated() { [ -n "$(ls -A "$1" 2>/dev/null)" ]; } +# release_assets_expected reports whether this run expects to download a release's images and +# Velocity plugin (select_release_artifacts): on the release channel, and from the setup +# console, whose binary is a release's. +release_assets_expected() { + [ -z "$FELIS_ARTIFACT_DIR" ] || return 1 + bootstrap_from_tui || use_release_binary +} + +# host_builds_expected reports whether this run expects to build images here, and so to install +# Docker: a source build does, an install from a release's assets does not, and the game images +# under FELIS_GAME_STACK=latest are always built here, since no release ships that stack. What +# preflight cannot see coming is a release that turns out to carry no usable images (one cut +# before they were published, or still uploading); that one is built here after all. +host_builds_expected() { + [ "$FELIS_GAME_STACK" != latest ] || return 0 + [ -z "$FELIS_ARTIFACT_DIR" ] || return 1 + ! release_assets_expected +} + # preflight_disk checks each filesystem the install writes to against what it will # write there, in MiB: k3s's images and volumes, the database and its bundles under -# /var/lib/felis, the sources, toolchains and proxy under /opt/felis, and Docker's image -# builds (operations.md §2 has the measured sizes). A directory that already holds -# something (a rerun, a reused k3s, Docker's cache from an earlier install) needs only -# the room for what changes. +# /var/lib/felis (and a release's image bundles on their way in), the sources, toolchains +# and proxy under /opt/felis, and Docker's image builds (operations.md §2 has the measured +# sizes). A directory that already holds something (a rerun, a reused k3s, Docker's cache +# from an earlier install) needs only the room for what changes. preflight_disk() { local spec path need_empty need_populated need line rows="" mount size used avail - for spec in "/var/lib/rancher 10240 3072" "/var/lib/felis 2048 1024" "/opt/felis 3072 1024" \ - "/var/lib/containerd 8192 2048"; do + local felis_spec="/var/lib/felis 2048 1024" docker_spec="" + release_assets_expected && felis_spec="/var/lib/felis 4096 3072" + host_builds_expected && docker_spec="/var/lib/containerd 8192 2048" + for spec in "/var/lib/rancher 10240 3072" "$felis_spec" "/opt/felis 3072 1024" \ + ${docker_spec:+"$docker_spec"}; do read -r path need_empty need_populated <<<"$spec" need="$need_empty" path_populated "$path" && need="$need_populated" @@ -1216,16 +1290,26 @@ preflight_networks() { done <<<"$routes" } -# preflight_hosts prints the hosts this run downloads from, one per line. Package -# mirrors are left out: the package manager names its own. +# preflight_hosts prints the hosts this run downloads from, one "host required|optional" line +# each. An optional host is one the install only falls back to: Docker Hub when the images are +# expected from a release, and, from the setup console, the release lookup (without it every +# image is built here). Package mirrors are left out: the package manager names its own. preflight_hosts() { - printf '%s\n' github.com - if ! bootstrap_from_tui && [ -z "${FELIS_SKIP_FETCH:-}" ] && [ -z "$FELIS_REF_PINNED" ]; then - printf '%s\n' api.github.com + printf '%s\n' "github.com required" + if [ -z "$FELIS_ARTIFACT_DIR" ] && [ -z "${FELIS_SKIP_FETCH:-}" ] && [ -z "$FELIS_REF_PINNED" ]; then + if bootstrap_from_tui; then + printf '%s\n' "api.github.com optional" + else + printf '%s\n' "api.github.com required" + fi + fi + [ -x "$K3S_BIN" ] || printf '%s\n' "raw.githubusercontent.com required" + [ -n "$FELIS_VELOCITY_FORK_JAR" ] || printf '%s\n' "fill-data.papermc.io required" + if host_builds_expected; then + printf '%s\n' "registry-1.docker.io required" + elif [ -z "$FELIS_ARTIFACT_DIR" ]; then + printf '%s\n' "registry-1.docker.io optional" fi - [ -x "$K3S_BIN" ] || printf '%s\n' raw.githubusercontent.com - [ -n "$FELIS_VELOCITY_FORK_JAR" ] || printf '%s\n' fill-data.papermc.io - printf '%s\n' registry-1.docker.io } # host_reachable reports whether an HTTPS connection to $1 can be made: any answer @@ -1243,11 +1327,19 @@ host_reachable() { } preflight_outbound() { - local host unreachable=() - while IFS= read -r host; do + local host need unreachable=() fallback=() + while read -r host need; do [ -n "$host" ] || continue - host_reachable "$host" || unreachable+=("$host") + host_reachable "$host" && continue + if [ "$need" = optional ]; then + fallback+=("$host") + else + unreachable+=("$host") + fi done < <(preflight_hosts) + if [ "${#fallback[@]}" -gt 0 ]; then + warn "preflight: cannot reach ${fallback[*]} over HTTPS; the install goes on, but cannot build or pull here an image the release turns out not to supply" + fi [ "${#unreachable[@]}" -eq 0 ] && return 0 preflight_fail "cannot reach ${unreachable[*]} over HTTPS; the install downloads from there (check DNS, the firewall, or set https_proxy)" } @@ -1587,6 +1679,25 @@ install_docker() { ok "docker running" } +# ensure_docker installs and starts Docker the first time this run has something to build, and +# starts it again after an earlier step stopped it. Only what a release did not ship prebuilt +# is built here, so an install from a release's assets never installs Docker at all. +ensure_docker() { + if [ -z "$DOCKER_INSTALLED" ]; then + install_docker + DOCKER_INSTALLED=1 + else + systemctl start docker + fi +} + +# stop_docker hands back the ~150 MiB the docker daemon holds once a step is done with it; the +# next step that builds starts it again. A Docker this run never started is left alone. +stop_docker() { + [ -n "$DOCKER_INSTALLED" ] || return 0 + systemctl stop docker docker.socket 2>/dev/null || true +} + # --------------------------------------------------------------------------- # 4. k3s — single node, trimmed for RAM. NetworkPolicy stays ENABLED on purpose: # Felis's minecraft fence (default-deny + allow-rcon/allow-game) is a core @@ -1622,11 +1733,13 @@ install_k3s() { ok "k3s ${current:-(version unreadable)} already installed at ${K3S_BIN}; this release pins ${FELIS_K3S_VERSION} (FELIS_UPGRADE_DEPS=1 moves it)" elif k3s_upgrade_allowed "$current" "$FELIS_K3S_VERSION"; then log "upgrading k3s ${current} to ${FELIS_K3S_VERSION}; running pods keep running while it restarts" + stage_k3s_airgap_images run_k3s_installer installer_ran=1 fi else log "installing k3s ${FELIS_K3S_VERSION} into ${K3S_BIN_DIR} (no traefik/servicelb/metrics-server)" + stage_k3s_airgap_images run_k3s_installer installer_ran=1 fi @@ -1742,6 +1855,47 @@ run_k3s_installer() { sh - } +# stage_k3s_airgap_images puts the image tarball of the k3s release about to be installed where +# k3s imports images from as it starts, so k3s's own images (pause, CoreDNS, the local-path +# provisioner) come from that GitHub release, checked against its sha256sum file, instead of +# from Docker Hub, whose anonymous pull limit (10 an hour per address) a shared VPS address may +# have spent already. Best-effort: without the file k3s pulls them as it always has. k3s only +# reads names ending in a tarball extension, so the download's dotted temp name is never read. +stage_k3s_airgap_images() { + local arch base file sums want have tmp + arch="$(felis_asset_arch)" || return 0 + base="https://github.com/k3s-io/k3s/releases/download/${FELIS_K3S_VERSION}" + file="k3s-airgap-images-${arch}.tar.zst" + sums="$(curl -fsSL --retry 5 --retry-delay 2 "${base}/sha256sum-${arch}.txt")" || sums="" + want="$(awk -v n="$file" '$2 == n { print $1; exit }' <<<"$sums")" + if ! [[ "$want" =~ ^[0-9a-f]{64}$ ]]; then + warn "k3s ${FELIS_K3S_VERSION} lists no sha256 for ${file}; k3s pulls its own images from Docker Hub instead" + return 0 + fi + if [ -f "${K3S_IMAGES_DIR}/${file}" ] && [ "$(sha256sum <"${K3S_IMAGES_DIR}/${file}" | cut -d' ' -f1)" = "$want" ]; then + ok "k3s ${FELIS_K3S_VERSION}'s images already staged" + return 0 + fi + mkdir -p "$K3S_IMAGES_DIR" + tmp="$(mktemp "${K3S_IMAGES_DIR}/.${file}.XXXXXX")" + remember_temp "$tmp" + log "downloading k3s ${FELIS_K3S_VERSION}'s own images (${file})" + if ! curl -fsSL --retry 5 --retry-delay 2 -o "$tmp" "${base}/${file}"; then + rm -f "$tmp" + warn "could not download ${file}; k3s pulls its own images from Docker Hub instead" + return 0 + fi + have="$(sha256sum <"$tmp" | cut -d' ' -f1)" + if [ "$have" != "$want" ]; then + rm -f "$tmp" + warn "${file} hashes to ${have}, but k3s ${FELIS_K3S_VERSION}'s sha256sum-${arch}.txt says ${want}; k3s pulls its own images from Docker Hub instead" + return 0 + fi + chmod 0644 "$tmp" + mv -f "$tmp" "${K3S_IMAGES_DIR}/${file}" + ok "staged k3s ${FELIS_K3S_VERSION}'s images for its first start" +} + # k3s_upgrade_allowed decides whether an installed k3s ($1) may move to $2. Kubernetes # supports upgrading one minor version at a time, so a larger jump stops the install # before anything changed; a newer installed k3s is left as it is. @@ -1967,6 +2121,14 @@ felis_asset_arch() { esac } +# load_release_json keeps release tag $1's JSON in RELEASE_JSON, fetching it only for a tag it +# does not hold yet. +load_release_json() { + [ "$RELEASE_JSON_TAG" != "$1" ] || return 0 + RELEASE_JSON="$(github_api "repos/$(repo_slug)/releases/tags/$1")" || return 1 + RELEASE_JSON_TAG="$1" +} + # github_asset_id prints the numeric id of the asset named $2 on release tag $1. # # Two details here are load-bearing and both are wrong in the obvious version. The newline @@ -1981,10 +2143,10 @@ felis_asset_arch() { # precedes it. Anything published after uploader (size, digest, browser_download_url) is NOT # reachable this way — fetch releases/assets/ with the JSON Accept if that is ever needed. github_asset_id() { - local tag="$1" name="$2" json id + local tag="$1" name="$2" id # Fetch first, filter second — the SIGPIPE reason documented on resolve_latest_game_jars. - json="$(github_api "repos/$(repo_slug)/releases/tags/${tag}")" || return 1 - id="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' \ + load_release_json "$tag" || return 1 + id="$(printf '%s' "$RELEASE_JSON" | tr -d '\n' | tr '{' '\n' \ | grep "\"name\":[[:space:]]*\"${name}\"" \ | grep -o 'releases/assets/[0-9]\{1,\}' | head -1)" || true id="${id##*/}" @@ -2013,6 +2175,8 @@ github_asset_id() { download_release_asset() { local tag="$1" name="$2" dest="$3" id ua url rc=0 ua="felis-bootstrap (+${FELIS_REPO_URL})" + # Loaded here, in this shell, so the lookup's subshell below finds it cached. + load_release_json "$tag" || return 1 id="$(github_asset_id "$tag" "$name")" || return 1 url="https://api.github.com/repos/$(repo_slug)/releases/assets/${id}" log "downloading ${name} from release ${tag}" @@ -2148,6 +2312,159 @@ download_release_binary() { ok "installed ${asset} ${FELIS_REF} at ${HOST_BIN}" } +# --------------------------------------------------------------------------- +# 5a. A release's prebuilt assets: every image and the Velocity plugin, next to the binary +# (deploy/build-release-artifacts.sh writes them, release.yml publishes them). Each file +# is used only once its sha256 matches the release's SHA256SUMS. +# --------------------------------------------------------------------------- + +# load_artifact_sums reads the SHA256SUMS the artifacts are checked against: the directory's, +# or release ARTIFACT_TAG's. It fails when there is none. +load_artifact_sums() { + local tmp + ARTIFACT_SUMS="" + if [ "$ARTIFACT_MODE" = dir ]; then + ARTIFACT_SUMS="$(cat "${FELIS_ARTIFACT_DIR}/SHA256SUMS" 2>/dev/null)" || ARTIFACT_SUMS="" + else + tmp="$(mktemp)" + remember_temp "$tmp" + if download_release_asset "$ARTIFACT_TAG" SHA256SUMS "$tmp"; then + ARTIFACT_SUMS="$(cat "$tmp")" + fi + rm -f "$tmp" + fi + [ -n "$ARTIFACT_SUMS" ] +} + +# artifact_sum prints the sha256 SHA256SUMS lists for , or nothing. sha256sum's +# text-mode line is " ", binary mode " *". No regex interval here: +# Debian's mawk does not take one. +artifact_sum() { + awk -v n="$1" '($2 == n || $2 == "*" n) && length($1) == 64 && $1 !~ /[^0-9a-f]/ { print $1; exit }' <<<"$ARTIFACT_SUMS" +} + +# artifact_fetch points ARTIFACT_FILE at a local copy of artifact whose sha256 is +# the one SHA256SUMS lists: the directory's own file, or a download kept in ARTIFACT_CACHE (a +# copy already there that still matches is used as it is, so a rerun after a failure fetches +# nothing twice). It warns and fails, leaving nothing half-written behind, when SHA256SUMS +# does not list the name, the file is missing, or its bytes differ. +artifact_fetch() { + local name="$1" want have file partial + ARTIFACT_FILE="" + want="$(artifact_sum "$name")" + if [ -z "$want" ]; then + warn "SHA256SUMS lists no ${name}" + return 1 + fi + if [ "$ARTIFACT_MODE" = dir ]; then + file="${FELIS_ARTIFACT_DIR}/${name}" + if [ ! -f "$file" ]; then + warn "${file} is missing" + return 1 + fi + else + file="${ARTIFACT_CACHE}/${name}" + fi + if [ -f "$file" ]; then + have="$(sha256sum <"$file" | cut -d' ' -f1)" + if [ "$have" = "$want" ]; then + ARTIFACT_FILE="$file" + return 0 + fi + if [ "$ARTIFACT_MODE" = dir ]; then + warn "${file} hashes to ${have}, but SHA256SUMS says ${want}" + return 1 + fi + rm -f "$file" + fi + install -d -m 0700 "$ARTIFACT_CACHE" + partial="${file}.partial" + if ! download_release_asset "$ARTIFACT_TAG" "$name" "$partial"; then + rm -f "$partial" + warn "could not download ${name} from release ${ARTIFACT_TAG}" + return 1 + fi + have="$(sha256sum <"$partial" | cut -d' ' -f1)" + if [ "$have" != "$want" ]; then + rm -f "$partial" + warn "downloaded ${name} hashes to ${have}, but release ${ARTIFACT_TAG}'s SHA256SUMS says ${want}" + return 1 + fi + mv -f "$partial" "$file" + ARTIFACT_FILE="$file" +} + +# artifact_unusable handles an asset this run cannot use. With +# FELIS_ARTIFACT_DIR it stops the install: the operator named the directory so that nothing +# would be built or pulled here. For a downloaded release it warns, and (that piece +# built on this host, or pulled) takes its place. +artifact_unusable() { + [ "$ARTIFACT_MODE" != dir ] || die "FELIS_ARTIFACT_DIR: $1" + warn "$1; $2" +} + +# install_artifact_binary installs FELIS_ARTIFACT_DIR's felis binary, and never falls back to a +# build: a missing or mismatched file is for the operator to fix. The copy is staged next to +# HOST_BIN for download_release_binary's reason (the directory, like /tmp, may be mounted +# noexec), and hashed there, after the copy, so the bytes checked are the bytes run. Its +# version names the control-plane image and the release the rest of the directory must be. +install_artifact_binary() { + local arch name tmp got + ARTIFACT_MODE=dir + arch="$(felis_asset_arch)" || die "FELIS_ARTIFACT_DIR: Felis publishes no binary for $(uname -m)" + name="felis-linux-${arch}" + load_artifact_sums || die "FELIS_ARTIFACT_DIR: ${FELIS_ARTIFACT_DIR} holds no SHA256SUMS" + [ -n "$(artifact_sum "$name")" ] || die "FELIS_ARTIFACT_DIR: SHA256SUMS lists no ${name}" + mkdir -p "$(dirname "$HOST_BIN")" + tmp="$(mktemp "$(dirname "$HOST_BIN")/.felis-download.XXXXXX")" + remember_temp "$tmp" + cp "${FELIS_ARTIFACT_DIR}/${name}" "$tmp" || die "FELIS_ARTIFACT_DIR: cannot read ${FELIS_ARTIFACT_DIR}/${name}" + [ "$(sha256sum <"$tmp" | cut -d' ' -f1)" = "$(artifact_sum "$name")" ] \ + || die "FELIS_ARTIFACT_DIR: ${name} does not match SHA256SUMS" + chmod 0755 "$tmp" + got="$("$tmp" version 2>/dev/null | head -n 1 || true)" + case "$got" in + "felis "?*) ;; + *) die "FELIS_ARTIFACT_DIR: ${name} reports '${got:-nothing}' as its version" ;; + esac + FELIS_VERSION="${got#felis }" + if [ -x "$HOST_BIN" ] && cmp -s "$tmp" "$HOST_BIN"; then + ok "host binary is already felis ${FELIS_VERSION} from ${FELIS_ARTIFACT_DIR}" + else + keep_previous_host_binary + rm -f "$HOST_BIN" + install -m 0755 "$tmp" "$HOST_BIN" + command -v restorecon >/dev/null 2>&1 && restorecon "$HOST_BIN" >/dev/null 2>&1 || true + ok "installed felis ${FELIS_VERSION} from ${FELIS_ARTIFACT_DIR}" + fi + rm -f "$tmp" + HAVE_PREBUILT_BINARY=1 +} + +# select_release_artifacts decides, once the felis binary is on the host, where this run's +# images and Velocity plugin come from: FELIS_ARTIFACT_DIR, else the assets of the release the +# binary is (the one just downloaded, or the one the setup console runs). A source build +# builds them too, and so does a release without SHA256SUMS (cut before release.yml wrote +# one, or still uploading), since nothing of it could be checked. +select_release_artifacts() { + local v + [ "$ARTIFACT_MODE" != dir ] || return 0 + [ -n "$HAVE_PREBUILT_BINARY" ] || return 0 + v="$("$HOST_BIN" version 2>/dev/null | head -n 1 || true)" + v="${v#felis }" + # A release's version is its tag; a dev or pinned build's names no release. + [[ "$v" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || return 0 + ARTIFACT_MODE=release + ARTIFACT_TAG="$v" + if load_artifact_sums; then + ok "release ${v}'s prebuilt images and Velocity plugin are installed as published" + return 0 + fi + ARTIFACT_MODE="" + ARTIFACT_TAG="" + warn "release ${v} publishes no SHA256SUMS, so none of its images can be checked; building them on this host instead (this installs Docker and needs about 8 GiB more under /var/lib/containerd)" +} + # git_auth runs git with the token supplied by an inline credential helper. The helper # is a shell snippet that READS the exported variable when git asks; the token is never # in argv and never reaches .git/config, so it does not outlive the process. @@ -2364,7 +2681,11 @@ image_tag_for_version() { } build_image() { - systemctl start docker + if role_prebuilt felis; then + ok "${FELIS_IMAGE} is the release's own image; nothing to build" + return 0 + fi + ensure_docker # Keyed on the binary, not on the route that produced it: the TUI hand-off and a release # download both land on exactly the same state (a felis binary at HOST_BIN, no checkout), # and a release download that fell back to source has cleared this so the source build runs. @@ -2393,6 +2714,159 @@ remove_k3s_image() { esac } +# role_image prints the name this install runs 's image under: the names above for +# the images Felis builds, the name CRI gives a digest-pinned pull for the two it does not. +role_image() { + case "$1" in + felis) printf '%s\n' "$FELIS_IMAGE" ;; + limbo) printf '%s\n' "$FELIS_LIMBO_IMAGE" ;; + lobby) printf '%s\n' "$FELIS_LOBBY_IMAGE" ;; + paper) printf '%s\n' "$FELIS_PAPER_IMAGE" ;; + registry) pinned_image_ref "$REGISTRY_IMAGE" ;; + postgres) pinned_image_ref "$POSTGRES_IMAGE" ;; + esac +} + +# role_fallback prints what takes the place of a release image this run cannot use. +role_fallback() { + case "$1" in + registry|postgres) printf 'k3s pulls it from Docker Hub instead' ;; + *) printf 'building it on this host instead' ;; + esac +} + +# role_prebuilt reports whether 's image came from the release (import_release_images). +role_prebuilt() { + case "$PREBUILT_ROLES" in *" $1 "*) return 0 ;; esac + return 1 +} + +# image_repo prints with its digest and tag dropped. +image_repo() { + local r="${1%%@*}" + case "${r##*/}" in *:*) r="${r%:*}" ;; esac + printf '%s\n' "$r" +} + +# image_present reports whether the listing holds +# (its header row starts with REF, which no image is named). A tag has to name +# too, since a tag moves; a digest name is the content it names, whether a +# bundle imported the platform manifest under it or CRI pulled the whole index. +image_present() { + case "$2" in + *@*) awk -v r="$2" '$1 == r { f = 1 } END { exit !f }' <<<"$1" ;; + *) awk -v r="$2" -v d="$3" '$1 == r && $3 == d { f = 1 } END { exit !f }' <<<"$1" ;; + esac +} + +# load_release_listing keeps the image listing's lines in RELEASE_LISTING once every +# line is one bundle of this architecture, a role Felis knows once, two sha256 digests and a +# name made of image-reference characters. A single line that is not is reason enough to trust +# none: the listing is what decides which tar each image is read from. +load_release_listing() { + local file="$1" arch="$2" bundle role name digest config rest out="" seen=" " + while read -r bundle role name digest config rest; do + [ -n "$bundle" ] || continue + [ -z "$rest" ] || return 1 + [[ "$bundle" =~ ^felis-image-[a-z]+-linux-${arch}\.tar$ ]] || return 1 + case "$role" in felis|limbo|lobby|paper|registry|postgres) ;; *) return 1 ;; esac + case "$seen" in *" $role "*) return 1 ;; esac + seen="${seen}${role} " + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] && [[ "$config" =~ ^sha256:[0-9a-f]{64}$ ]] || return 1 + [[ "$name" =~ ^[A-Za-z0-9._:/@-]+$ ]] || return 1 + out="${out}${bundle} ${role} ${name} ${digest} ${config}"$'\n' + done <"$file" + [ -n "$out" ] || return 1 + RELEASE_LISTING="$out" +} + +# release_listing reads this architecture's image listing once per run, and fails, after one +# warning (or, from FELIS_ARTIFACT_DIR, the end of the install), when it cannot be used. +release_listing() { + local arch name + case "$RELEASE_LISTING_STATE" in + ok) return 0 ;; + bad) return 1 ;; + esac + RELEASE_LISTING_STATE=bad + arch="$(felis_asset_arch)" || return 1 + name="felis-images-linux-${arch}.txt" + if ! artifact_fetch "$name"; then + artifact_unusable "the release's image listing ${name} cannot be used" "building its images on this host instead" + return 1 + fi + if ! load_release_listing "$ARTIFACT_FILE" "$arch"; then + artifact_unusable "the release's image listing ${name} is malformed" "building its images on this host instead" + return 1 + fi + RELEASE_LISTING_STATE=ok +} + +# import_release_images ... puts each role's image into k3s containerd from the release's +# bundles, and records it as prebuilt: build_image and build_game_stack skip it, and +# push_images_to_registry pushes it from its bundle. Only the bundles holding an image +# containerd lacks are fetched and imported, so a rerun, or an upgrade that changed only the +# control plane, downloads just that. A bundle names each image by the name the installer +# runs it under by default; a FELIS_*_IMAGE set to another name gets that name as a second tag +# on the same image. A role the release cannot supply falls back to role_fallback, one image +# at a time: the rest still install as published. +import_release_images() { + local role line bundle name digest config target images todo="" needed="" b + [ -n "$ARTIFACT_MODE" ] || return 0 + release_listing || return 0 + # Read the list whole before matching: the SIGPIPE reason on import_platform_images. + images="$(k3s_cmd ctr images ls 2>/dev/null || true)" + for role in "$@"; do + line="$(awk -v r="$role" '$2 == r' <<<"$RELEASE_LISTING")" + if [ -z "$line" ]; then + artifact_unusable "the release lists no ${role} image" "$(role_fallback "$role")" + continue + fi + read -r bundle _ name digest config <<<"$line" + target="$(role_image "$role")" + case "$role" in + registry|postgres) + # Docker Hub's copy is pinned by digest here; the release's must be that one. + if [ "$name" != "$target" ]; then + artifact_unusable "the release's ${role} image is ${name}, but this installer runs ${target}" "$(role_fallback "$role")" + continue + fi + ;; + esac + todo="${todo}${role} ${bundle} ${name} ${target} ${digest} ${config}"$'\n' + if ! image_present "$images" "$target" "$digest"; then + case "${needed} " in *" ${bundle} "*) ;; *) needed="${needed} ${bundle}" ;; esac + fi + done + for b in $needed; do + # artifact_fetch says why it failed; the images the bundle holds are refused below. + artifact_fetch "$b" || continue + log "importing ${b} into k3s containerd" + k3s_cmd ctr images import "$ARTIFACT_FILE" >/dev/null || warn "k3s containerd could not import ${b}" + done + [ -z "$needed" ] || images="$(k3s_cmd ctr images ls 2>/dev/null || true)" + while read -r role bundle name target digest config; do + [ -n "$role" ] || continue + if ! image_present "$images" "$target" "$digest"; then + if ! image_present "$images" "$name" "$digest" \ + || ! k3s_cmd ctr images tag --force "$name" "$target" >/dev/null \ + || ! k3s_cmd ctr images tag --force "$name" "$(image_repo "$target")@${digest}" >/dev/null; then + artifact_unusable "k3s containerd holds no ${target} from ${bundle}" "$(role_fallback "$role")" + continue + fi + fi + PREBUILT_ROLES="${PREBUILT_ROLES}${role} " + RELEASE_IMAGES="${RELEASE_IMAGES}${role} ${bundle} ${name} ${target} ${digest} ${config}"$'\n' + # The build each system server runs, for restart_existing_system_servers: the image's + # config digest, the id docker gives the same image. + case "$role" in + limbo) LIMBO_IMAGE_ID="$config" ;; + lobby) LOBBY_IMAGE_ID="$config" ;; + esac + ok "${target} is the release's (${digest:7:12})" + done <<<"$todo" +} + # --------------------------------------------------------------------------- # 5b. The game stack: the login limbo + lobby images, and the Velocity proxy. # @@ -2608,51 +3082,90 @@ papermc_latest_jar() { } build_game_stack() { - systemctl start docker + local role docker_used="" game_stack_source resolve_game_jars + # A release's game images are built from its game-stack.lock, the pinned stack. The latest + # stack is resolved on this host at install time, so it is built here. + if [ "$FELIS_GAME_STACK" = pinned ]; then + import_release_images limbo lobby paper + fi + for role in limbo lobby paper; do + role_prebuilt "$role" && continue + [ -n "$docker_used" ] || ensure_docker + docker_used=1 + build_game_image "$role" + done + install_velocity_plugin + stop_docker + ok "login + lobby images imported; felis-velocity.jar staged" +} - log "building ${FELIS_LIMBO_IMAGE} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" - docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ - --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ - --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ - --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ - --build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \ - --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ - -t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR" - - log "building ${FELIS_LOBBY_IMAGE} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" - docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ - --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ - --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ - --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ - --build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \ - -t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR" - - # Plain Paper, same MC_VERSION and PAPER_JAR_URL (no new dependency). Forwarding is the - # operator initContainer's job, so this image carries no /menu plugin and no secret gate. - log "building ${FELIS_PAPER_IMAGE} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" - docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ - --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ - --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ - -t "$FELIS_PAPER_IMAGE" "$GAME_STACK_DIR" +# build_game_image builds one game image on this host and imports it into k3s containerd. +build_game_image() { + local img + case "$1" in + limbo) + img="$FELIS_LIMBO_IMAGE" + log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" + docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ + --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ + --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ + --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ + --build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \ + --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ + -t "$img" "$GAME_STACK_DIR" + ;; + lobby) + img="$FELIS_LOBBY_IMAGE" + log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" + docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ + --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ + --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ + --build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \ + -t "$img" "$GAME_STACK_DIR" + ;; + paper) + # Plain Paper, same MC_VERSION and PAPER_JAR_URL (no new dependency). Forwarding is the + # operator initContainer's job, so this image carries no /menu plugin and no secret gate. + img="$FELIS_PAPER_IMAGE" + log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" + docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ + --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ + -t "$img" "$GAME_STACK_DIR" + ;; + esac # The builds the system servers run, for restart_existing_system_servers. Docker's layer # cache gives an unchanged build the same id, so a rerun that rebuilt nothing leaves the # login and lobby pods (and every player on them) alone. - LIMBO_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$FELIS_LIMBO_IMAGE")" - LOBBY_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$FELIS_LOBBY_IMAGE")" + case "$1" in + limbo) LIMBO_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$img")" ;; + lobby) LOBBY_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$img")" ;; + esac - local img - for img in "$FELIS_LIMBO_IMAGE" "$FELIS_LOBBY_IMAGE" "$FELIS_PAPER_IMAGE"; do - log "importing ${img} into k3s containerd" - remove_k3s_image "$img" - docker save "$img" | k3s_cmd ctr images import - - done + log "importing ${img} into k3s containerd" + remove_k3s_image "$img" + docker save "$img" | k3s_cmd ctr images import - +} +# install_velocity_plugin puts the release's felis-velocity.jar in place, or builds one here +# when the release has none this run can use. The jar is JVM bytecode, one file for every +# architecture. +install_velocity_plugin() { + if [ -n "$ARTIFACT_MODE" ]; then + if artifact_fetch felis-velocity.jar; then + prepare_velocity_layout + install_if_changed "$ARTIFACT_FILE" "${VELOCITY_DIR}/plugins/felis-velocity.jar" 0644 root root + ok "felis-velocity.jar is the release's" + return 0 + fi + artifact_unusable "the release's felis-velocity.jar cannot be used" "building it on this host instead" + fi + ensure_docker build_velocity_plugin - systemctl stop docker docker.socket 2>/dev/null || true - ok "login + lobby images imported; felis-velocity.jar staged" } ensure_velocity_directory() { @@ -4469,19 +4982,80 @@ registry_docker_login() { # a re-run: three fast pushes, then "Start request repeated too quickly / # start-limit-hit" and the fourth image never got mirrored. docker.service is # socket-triggered, so each cycle counts twice against the burst limit. +# +# An image the release shipped (import_release_images) is pushed from its bundle by felis +# push-image, and needs no Docker at all; only the images built here go through docker push. push_images_to_registry() { - local img - systemctl start docker - registry_docker_login - for img in "$FELIS_IMAGE" "$FELIS_LIMBO_IMAGE" "$FELIS_LOBBY_IMAGE" "$FELIS_PAPER_IMAGE"; do + local role img docker_used="" + for role in felis limbo lobby paper; do + role_prebuilt "$role" || continue + push_release_image "$role" + done + for role in felis limbo lobby paper; do + role_prebuilt "$role" && continue + img="$(role_image "$role")" [ -n "$img" ] || continue + if [ -z "$docker_used" ]; then + ensure_docker + registry_docker_login + docker_used=1 + fi push_image_to_registry "$img" + [ "$role" = felis ] || push_version_tag "$img" done - for img in "$FELIS_LIMBO_IMAGE" "$FELIS_LOBBY_IMAGE" "$FELIS_PAPER_IMAGE"; do - [ -n "$img" ] || continue - push_version_tag "$img" + stop_docker + # Every bundle is in containerd and the registry now; a rerun that needs one fetches it again. + if [ "$ARTIFACT_MODE" = release ]; then + rm -rf -- "$ARTIFACT_CACHE" + fi +} + +# registry_manifest_digest prints the digest the platform registry holds +# under that tag, or nothing. Reads are anonymous at the gate. +registry_manifest_digest() { + local ref="$1" repo tag + repo="${ref#*/}" + tag="${repo##*:}" + repo="${repo%:*}" + curl -fsSI --max-time 30 \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + "http://${ref%%/*}/v2/${repo}/manifests/${tag}" 2>/dev/null \ + | tr -d '\r' | awk 'tolower($1) == "docker-content-digest:" { print $2 }' || true +} + +# push_release_image mirrors a release image into the platform registry under its tag +# and, for a game image, under push_version_tag's -<12 hex of its id> as +# well, the id being the image's config digest here as it is docker's image id there. A tag +# already naming the image's digest is left as it is, so a rerun uploads nothing. +push_release_image() { + local role="$1" bundle name target digest config refs ref push_ref + read -r _ bundle name target digest config <<<"$(awk -v r="$role" '$1 == r' <<<"$RELEASE_IMAGES")" + case "$target" in + "${REGISTRY_URL}/"*) ;; + *) + warn "not mirroring ${target} into the internal registry: it is not under ${REGISTRY_URL}; once the image GC collects that tag, nothing can re-pull it" + return 0 + ;; + esac + refs="$target" + if [ "$role" != felis ] && [ -n "${MC_VERSION:-}" ]; then + refs="${refs} ${target%:*}:${MC_VERSION}-${config:7:12}" + fi + for ref in $refs; do + push_ref="${REGISTRY_PUSH_HOST}/${ref#"${REGISTRY_URL}/"}" + if [ "$(registry_manifest_digest "$push_ref")" = "$digest" ]; then + ok "${ref} is already in the internal registry" + continue + fi + artifact_fetch "$bundle" || die "could not mirror ${ref} into the internal registry: ${bundle} (above) is gone" + log "mirroring ${ref} into the internal registry" + FELIS_REGISTRY_USERNAME=platform FELIS_REGISTRY_PASSWORD="$REGISTRY_PLATFORM_TOKEN" \ + "$HOST_BIN" push-image --tar "$ARTIFACT_FILE" --image "$name" --ref "$push_ref" >/dev/null \ + || die "could not mirror ${ref} into the internal registry — check the registry Deployment/pod (the registry, registry-gate and registry-gc containers) and its PVC" done - systemctl stop docker docker.socket 2>/dev/null || true } # push_version_tag mirrors a game image a second time under a tag no later run @@ -4954,30 +5528,37 @@ main() { # do not have: the TUI rebuilds the binary it is already running, and FELIS_SKIP_FETCH # builds whatever is staged, which stamp_version reads the SHA off. Resolving anyway # would set FELIS_VERSION to the newest tag and stamp a staged tree as that release. - bootstrap_from_tui || [ -n "${FELIS_SKIP_FETCH:-}" ] || resolve_install_ref + # FELIS_ARTIFACT_DIR is its own release: its binary names the version. + bootstrap_from_tui || [ -n "${FELIS_SKIP_FETCH:-}" ] || [ -n "$FELIS_ARTIFACT_DIR" ] || resolve_install_ref install_cloudflared load_or_make_secrets configure_offsite ensure_panel_tls_cert - install_docker + # No install_docker here: Docker comes in only for an image this run has to build + # (ensure_docker), and an install from a release's assets builds none. install_k3s # The registry mirror must exist before the bundle's pods start pulling (and - # before any re-run's rollouts); the registry's and the database's own images must - # be in containerd before their Deployments can start at all. + # before any re-run's rollouts). configure_registry_mirror - import_platform_images - # Three ways to end up with a felis binary, in preference order. The release download is - # the only one that skips compiling: it is the CI artifact for this exact tag, panel - # included. Both other arms leave HAVE_PREBUILT_BINARY unset where a source build is what - # actually happens, which is what routes build_image below. - if bootstrap_from_tui; then + # Four ways to end up with a felis binary, in preference order. FELIS_ARTIFACT_DIR and the + # release download skip compiling: each is the CI artifact for its tag, panel included. The + # other arms leave HAVE_PREBUILT_BINARY unset where a source build is what actually + # happens, which is what routes build_image below. + if [ -n "$FELIS_ARTIFACT_DIR" ]; then + install_artifact_binary + elif bootstrap_from_tui; then install_embedded_binary elif use_release_binary && download_release_binary; then : else fetch_source fi + select_release_artifacts resolve_felis_image + # The registry's and the database's own images must be in containerd before their + # Deployments can start at all: from the release's bundle when it has them, else pulled. + import_release_images felis registry postgres + import_platform_images build_image # After build_image imported the felis image: the registry pod's gate runs it. pin_platform_images diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 3893cee..b09ec66 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -853,6 +853,7 @@ run_k3s() { # installed-version pinned-version [FELIS_UPGRADE_DEPS] write_k3s_config() { :; } strip_k3s_kubeconfig_mode_flag() { :; } run_k3s_installer() { printf "INSTALLER: %s\n" "$FELIS_K3S_VERSION"; } + stage_k3s_airgap_images() { echo STAGE; } systemctl() { :; } wait_for_node_ready() { :; } chmod() { :; } @@ -864,10 +865,15 @@ run_k3s() { # installed-version pinned-version [FELIS_UPGRADE_DEPS] out="$(run_k3s v1.36.4+k3s1 v1.36.4+k3s1 1)" expect "a k3s at the pin is left alone" "OK: k3s v1.36.4+k3s1 already installed" "$out" case "$out" in *INSTALLER:*) echo "FAIL: a k3s at the pin must not be reinstalled"; fails=$((fails + 1)) ;; esac +# k3s's image tarball is read as k3s starts, so it is fetched only for a k3s about to start +# on a new version: a rerun downloads nothing. +case "$out" in *STAGE*) echo "FAIL: a k3s left as it is must not have its images downloaded again"; fails=$((fails + 1)) ;; *) echo "PASS a k3s left as it is stages no images" ;; esac out="$(run_k3s v1.35.2+k3s1 v1.36.4+k3s1)" expect "an older k3s is reported without the flag" "this release pins v1.36.4+k3s1 (FELIS_UPGRADE_DEPS=1 moves it)" "$out" case "$out" in *INSTALLER:*) echo "FAIL: an installed k3s must not move without FELIS_UPGRADE_DEPS=1"; fails=$((fails + 1)) ;; esac expect "FELIS_UPGRADE_DEPS=1 moves k3s up one minor" "INSTALLER: v1.36.4+k3s1" "$(run_k3s v1.35.2+k3s1 v1.36.4+k3s1 1)" +expect "an upgrade stages the new k3s's images before its installer restarts it" "STAGE +INSTALLER: v1.36.4+k3s1" "$(run_k3s v1.35.2+k3s1 v1.36.4+k3s1 1)" expect "FELIS_UPGRADE_DEPS=1 moves k3s to a newer patch" "INSTALLER: v1.36.4+k3s1" "$(run_k3s v1.36.1+k3s2 v1.36.4+k3s1 1)" out="$(run_k3s v1.34.6+k3s1 v1.36.4+k3s1 1)" expect "a k3s upgrade that skips a minor is refused" "DIE: k3s v1.34.6+k3s1 -> v1.36.4+k3s1 skips a minor version" "$out" @@ -1156,22 +1162,28 @@ rm -rf "$pushdir" # docker must be started ONCE for the whole batch: a start/stop pair per image trips # systemd's start rate limit ("start-limit-hit" — observed live; the 4th image was never -# mirrored because docker.service is socket-triggered and each cycle counts twice). -wiblock="$(awk '/^push_images_to_registry\(\) \{/,/^}/' "$BS")" -[ -n "$wiblock" ] || { echo "FAIL: no push_images_to_registry found in $BS"; exit 1; } -out="$( - FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d bash -c ' +# mirrored because docker.service is socket-triggered and each cycle counts twice). An image +# the release shipped is pushed from its bundle instead, and needs no Docker at all. +wiblock="$(for f in push_images_to_registry role_prebuilt role_image stop_docker; do awk '/^'"$f"'\(\) \{/,/^}/' "$BS"; done)" +case "$wiblock" in *"push_images_to_registry() {"*"role_prebuilt() {"*"role_image() {"*"stop_docker() {"*) ;; *) echo "FAIL: push_images_to_registry or its helpers are missing from $BS"; exit 1 ;; esac +run_batch() { # PREBUILT_ROLES [ARTIFACT_MODE [ARTIFACT_CACHE]] + FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d PREBUILT_ROLES="$1" \ + ARTIFACT_MODE="${2:-}" ARTIFACT_CACHE="${3:-/nonexistent}" bash -c ' + DOCKER_INSTALLED="" systemctl() { printf "SYSTEMCTL %s\n" "$*"; } + ensure_docker() { printf "ENSURE\n"; DOCKER_INSTALLED=1; } push_image_to_registry() { printf "PUSH %s\n" "$1"; } push_version_tag() { printf "VERSION %s\n" "$1"; } + push_release_image() { printf "RELEASE %s\n" "$1"; } registry_docker_login() { printf "LOGIN\n"; } '"$wiblock"' push_images_to_registry' -)" -expect "the batch logs in to the registry gate before pushing" "SYSTEMCTL start docker +} +out="$(run_batch " ")" +expect "the batch logs in to the registry gate before pushing" "ENSURE LOGIN PUSH a" "$out" -starts="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL start docker')" +starts="$(printf '%s\n' "$out" | grep -c 'ENSURE')" stops="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL stop docker')" [ "$starts" = 1 ] && [ "$stops" = 1 ] && [ "$(printf '%s\n' "$out" | grep -c '^PUSH')" = 4 ] \ && echo "PASS the batch wraps all four pushes in ONE docker start/stop" \ @@ -1179,6 +1191,24 @@ stops="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL stop docker')" [ "$(printf '%s\n' "$out" | grep '^VERSION' | tr '\n' ' ')" = "VERSION b VERSION c VERSION d " ] \ && echo "PASS the three game images, and only they, also get a version tag" \ || { echo "FAIL: expected version tags for b c d only, got:"; printf '%s\n' "$out"; fails=$((fails + 1)); } +out="$(run_batch " felis limbo ")" +expect "release images are pushed from their bundles, the rest with docker" "RELEASE felis +RELEASE limbo +ENSURE +LOGIN +PUSH c +VERSION c +PUSH d +VERSION d +SYSTEMCTL stop docker docker.socket" "$out" +case "$out" in *"PUSH a"*|*"PUSH b"*) echo "FAIL: a release image must not also go through docker push"; fails=$((fails + 1)) ;; *) echo "PASS a release image is pushed once" ;; esac +cachedir="$(mktemp -d)" +out="$(run_batch " felis limbo lobby paper " release "$cachedir")" +[ "$(printf '%s\n' "$out" | grep -c '^RELEASE')" = 4 ] && [ "$(printf '%s\n' "$out" | grep -c '^RELEASE')" = "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" ] \ + && echo "PASS an install from release images pushes without Docker, and never starts or stops it" \ + || { echo "FAIL: an all-release batch touched docker:"; printf '%s\n' "$out"; fails=$((fails + 1)); } +[ ! -e "$cachedir" ] && echo "PASS the downloaded bundles are removed once they are pushed" \ + || { echo "FAIL: ${cachedir} was left behind after the pushes"; fails=$((fails + 1)); rm -rf "$cachedir"; } # Each game build is also mirrored under -, a tag no later run # rewrites, so an admin can still name that exact build after :demo moves on. @@ -2082,12 +2112,16 @@ case "$out" in *) echo "FAIL restart_existing_control_plane died on a first install: $out"; fails=$((fails + 1)) ;; esac -mainblock="$(awk '/^main\(\) \{/,/^}/' "$BS")" -case "$mainblock" in - *"resolve_felis_image - build_image"*) echo "PASS the image is named before it is built" ;; - *) echo "FAIL main must call resolve_felis_image right before build_image"; fails=$((fails + 1)) ;; -esac +# The binary names the release, the release names the images, and each image is looked for in +# the release's bundles before anything is pulled or built. +imorder="$(awk '/^main\(\) \{/,/^}/' "$BS" | grep -nE '^[[:space:]]*(install_k3s|configure_registry_mirror|install_artifact_binary|fetch_source|select_release_artifacts|resolve_felis_image|import_release_images felis registry postgres|import_platform_images|build_image|pin_platform_images)$' | sed 's/^[0-9]*:[[:space:]]*//' | tr '\n' ' ')" +expect "main takes the binary, then the release's images, before pulling or building any" \ + "install_k3s configure_registry_mirror install_artifact_binary fetch_source select_release_artifacts resolve_felis_image import_release_images felis registry postgres import_platform_images build_image pin_platform_images " "$imorder" +if awk '/^main\(\) \{/,/^}/' "$BS" | grep -qE '^[[:space:]]*install_docker$'; then + echo "FAIL: main installs Docker up front; only a build may (ensure_docker)"; fails=$((fails + 1)) +else + echo "PASS main leaves Docker to the builds that need it" +fi # --- a rerun restarts only what changed ------------------------------------------------- # The proxy, the login and lobby pods and PostgreSQL each disconnect every player (or cut @@ -2488,6 +2522,7 @@ run_install_k3s() { # $1: installed version ("" = none), $2: drop-in changed (0| write_k3s_config() { [ "$CHANGED" = 0 ] || K3S_RESTART_NEEDED=1; } strip_k3s_kubeconfig_mode_flag() { :; } run_k3s_installer() { echo "INSTALLER"; printf "#!/bin/sh\n" > "$K3S_BIN"; command chmod +x "$K3S_BIN"; } + stage_k3s_airgap_images() { echo "STAGE"; } systemctl() { echo "SYSTEMCTL: $*"; } wait_for_node_ready() { echo "READY"; } chmod() { echo "CHMOD: $*"; } @@ -2503,6 +2538,8 @@ case "$out" in *"restart k3s"*) echo "FAIL unchanged k3s settings must not resta out="$(run_install_k3s "" 1)" expect "a fresh host runs the k3s installer and waits for the node" "INSTALLER SYSTEMCTL: enable --now k3s" "$out" +expect "a fresh host stages k3s's images before k3s first starts" "STAGE +INSTALLER" "$out" expect "a fresh host's kubeconfig is made root-only too" "CHMOD: 0600 /etc/rancher/k3s/k3s.yaml" "$out" case "$out" in *"restart k3s"*) echo "FAIL the k3s installer already started k3s on the new settings; no second restart"; fails=$((fails + 1)) ;; *) echo "PASS a fresh k3s is not restarted a second time" ;; esac out="$(run_install_k3s v1.35.2+k3s1 1 1)" @@ -2674,7 +2711,8 @@ wdblock="$(awk '/^warn_dynamic_node_ip\(\) \{/,/^}/' "$BS")" pfroot="$(mktemp -d)" # run_pf runs preflight with the stubbed facts in the environment; each defaults to a # healthy host: 8 GiB RAM, one 100 GiB filesystem with 60 GiB free, no listeners, no -# other cluster, a LAN route, every download host answering. +# other cluster, a LAN route, every download host answering. The install defaults to a +# source build (the dev channel), the one that writes the most and downloads from the most. run_pf() { PF_ROOT="$pfroot" bash -c ' set -Eeuo pipefail @@ -2713,11 +2751,14 @@ run_pf() { host="${host#https://}"; host="${host%/}" case " ${PF_DOWN:-} " in *" ${host} "*) echo 000 ;; *) echo 404 ;; esac } - bootstrap_from_tui() { return 1; } + bootstrap_from_tui() { [ -n "${PF_TUI:-}" ]; } + '"$(awk '/^use_release_binary\(\) \{/,/^}/' "$BS")"' FELIS_GAME_PORT=25565 FELIS_PANEL_NODEPORT=30443 REGISTRY_URL=registry.felis.svc:5000 PG_HOST_PORT=15432 POD_CIDR=10.42.0.0/16 SERVICE_CIDR=10.43.0.0/16 NODE_IP="${PF_NODE_IP:-192.168.1.20}" K3S_BIN="$PF_ROOT/k3s" BOOTSTRAP_DONE="$PF_ROOT/bootstrap.done" FELIS_REF_PINNED="" FELIS_VELOCITY_FORK_JAR="" FELIS_PREFLIGHT="${PF_MODE:-strict}" + FELIS_VERSION_BOOTSTRAP="${PF_CHANNEL:-dev}" FELIS_ARTIFACT_DIR="${PF_ARTIFACT_DIR:-}" + FELIS_GAME_STACK="${PF_GAME_STACK:-pinned}" '"$wdblock"' '"$pfblock"' # The host readers the section defines, answered from the same facts. @@ -2794,6 +2835,31 @@ expect "and the install goes on" "WENT ON" "$out" out="$(PF_DOWN="github.com fill-data.papermc.io" run_pf)" expect "unreachable download hosts are named together" "cannot reach github.com fill-data.papermc.io over HTTPS" "$out" +# An install from a release's assets builds nothing: no Docker cache under /var/lib/containerd, +# and Docker Hub only as the fallback for an image the release cannot supply. The downloaded +# bundles wait under /var/lib/felis until they are pushed, so that budget grows. +out="$(PF_CHANNEL=release PF_DF="/ 104857600 83886080 20971520" run_pf)" +expect "a release install fits where a source build does not" "OK: preflight passed" "$out" +out="$(PF_CHANNEL=release PF_DF="/ 104857600 88080384 16777216" run_pf)" +expect "a release install still needs room for its bundles" "/ has 16384 MiB free; this install writes about 17408 MiB there" "$out" +out="$(PF_ARTIFACT_DIR=/srv/felis-release PF_DF="/ 104857600 88080384 16777216" PF_DOWN="api.github.com registry-1.docker.io" run_pf)" +expect "an install from FELIS_ARTIFACT_DIR downloads no bundles and asks neither GitHub's API nor Docker Hub" "OK: preflight passed" "$out" +case "$out" in *"cannot reach"*) echo "FAIL: FELIS_ARTIFACT_DIR probed a host it never uses"; fails=$((fails + 1)) ;; *) echo "PASS FELIS_ARTIFACT_DIR probes only what it uses" ;; esac +out="$(PF_CHANNEL=release PF_DOWN=registry-1.docker.io run_pf)" +expect "a release install without Docker Hub is warned about" "WARN: preflight: cannot reach registry-1.docker.io over HTTPS; the install goes on" "$out" +expect "and goes on" "OK: preflight passed" "$out" +out="$(PF_DOWN=registry-1.docker.io run_pf)" +expect "a source build without Docker Hub is refused" "cannot reach registry-1.docker.io over HTTPS; the install downloads from there" "$out" +out="$(PF_CHANNEL=release PF_GAME_STACK=latest PF_DOWN=registry-1.docker.io run_pf)" +expect "FELIS_GAME_STACK=latest builds its images here, so it needs Docker Hub" "cannot reach registry-1.docker.io over HTTPS; the install downloads from there" "$out" +out="$(PF_CHANNEL=release PF_GAME_STACK=latest PF_DF="/ 104857600 83886080 20971520" run_pf)" +expect "and room for the builds" "/ has 20480 MiB free; this install writes about 25600 MiB there" "$out" +out="$(PF_CHANNEL=release PF_DOWN=api.github.com run_pf)" +expect "the release channel cannot install without GitHub's API" "cannot reach api.github.com over HTTPS; the install downloads from there" "$out" +out="$(PF_TUI=1 PF_DOWN=api.github.com run_pf)" +expect "the setup console only warns without it: its binary is already here" "WARN: preflight: cannot reach api.github.com" "$out" +expect "and goes on" "OK: preflight passed" "$out" + # Three problems, one report, nothing done. out="$(PF_MEM_KB=1000000 PF_ARCH=armv7l PF_DOWN=github.com run_pf)" expect "every problem is in the one report" "preflight found 3 problem(s); nothing on this host has been changed" "$out" @@ -3263,6 +3329,476 @@ before "the control plane is scaled back up before the rollouts are awaited" \ 'kube -n "$CONTROL_NS" scale deployment felis-api felis-operator --replicas=1' 'rollout status "$d"' "$dbblock" expect "the pods reach the database at its Service" 'write_felis_toml "${STATE_DIR}/felis.pod.toml" "$PG_SERVICE_ADDR"' "$dbblock" +# --- a release's prebuilt assets ---------------------------------------------------------- +# Every file is used only once its sha256 is the one SHA256SUMS lists; the interesting cases +# are the refusals, and that a refused file is never handed on. +for f in load_artifact_sums artifact_sum artifact_fetch artifact_unusable install_artifact_binary \ + select_release_artifacts load_release_listing release_listing import_release_images \ + push_release_image registry_manifest_digest stage_k3s_airgap_images install_velocity_plugin \ + build_game_stack load_release_json; do + [ -n "$(bsfn "$f")" ] || { echo "FAIL: no ${f} in $BS"; exit 1; } + [ "$(bsfn "$f" | wc -l)" -lt 90 ] \ + || { echo "FAIL: the extracted ${f} is not just the function -- did its closing brace move?"; exit 1; } +done +adir="$(mktemp -d)" +sha() { sha256sum <"$1" | cut -d' ' -f1; } +afblock="$(bsfn artifact_sum; bsfn artifact_fetch)" +# run_fetch_artifact [sums]: the directory is $adir/dir, the cache $adir/cache, +# and the "release" serves $adir/release/. +run_fetch_artifact() { + MODE="$1" NAME="$2" SUMS="${3:-$(cat "$adir/SUMS")}" A="$adir" bash -c ' + set -Eeuo pipefail + warn() { echo "WARN: $*"; } + download_release_asset() { echo "DOWNLOAD $2" >&2; [ -f "$A/release/$2" ] || return 1; cp "$A/release/$2" "$3"; } + ARTIFACT_MODE="$MODE" ARTIFACT_TAG=v9.9.9 ARTIFACT_SUMS="$SUMS" ARTIFACT_CACHE="$A/cache" + FELIS_ARTIFACT_DIR="$A/dir" + '"$afblock"' + if artifact_fetch "$NAME"; then echo "FILE $ARTIFACT_FILE"; cat "$ARTIFACT_FILE"; else echo REFUSED; fi' 2>&1 +} +mkdir -p "$adir/dir" "$adir/release" +printf 'the game images\n' > "$adir/dir/felis-image-game-linux-amd64.tar" +cp "$adir/dir/felis-image-game-linux-amd64.tar" "$adir/release/" +printf '%s felis-image-game-linux-amd64.tar\n%s *felis-velocity.jar\n' \ + "$(sha "$adir/dir/felis-image-game-linux-amd64.tar")" "$(printf 'the plugin\n' | sha256sum | cut -d' ' -f1)" > "$adir/SUMS" +out="$(run_fetch_artifact dir felis-image-game-linux-amd64.tar)" +expect "a directory's file that matches SHA256SUMS is used where it is" "FILE $adir/dir/felis-image-game-linux-amd64.tar" "$out" +out="$(run_fetch_artifact dir felis-velocity.jar)" +expect "a file the directory lacks is refused" "WARN: $adir/dir/felis-velocity.jar is missing" "$out" +case "$out" in *DOWNLOAD*) echo "FAIL: FELIS_ARTIFACT_DIR went to the release for a file it lacks"; fails=$((fails + 1)) ;; *) echo "PASS FELIS_ARTIFACT_DIR never downloads" ;; esac +printf 'not the plugin\n' > "$adir/dir/felis-velocity.jar" +out="$(run_fetch_artifact dir felis-velocity.jar)" +expect "a directory's file that does not match is refused, naming both hashes" "hashes to $(sha "$adir/dir/felis-velocity.jar"), but SHA256SUMS says" "$out" +case "$out" in *FILE*) echo "FAIL: a mismatched file was handed on"; fails=$((fails + 1)) ;; *) echo "PASS a mismatched file is never handed on" ;; esac +out="$(run_fetch_artifact dir felis-linux-amd64)" +expect "a name SHA256SUMS does not list is refused" "WARN: SHA256SUMS lists no felis-linux-amd64" "$out" +out="$(run_fetch_artifact release felis-linux-amd64)" +case "$out" in *DOWNLOAD*|*FILE*) echo "FAIL: a file SHA256SUMS does not list was fetched: $out"; fails=$((fails + 1)) ;; *) echo "PASS a file SHA256SUMS does not list is never downloaded" ;; esac +out="$(run_fetch_artifact dir felis-image-game-linux-amd64.tar "$(printf 'deadbeef felis-image-game-linux-amd64.tar\n')")" +expect "a SHA256SUMS line without a whole sha256 lists nothing" "WARN: SHA256SUMS lists no felis-image-game-linux-amd64.tar" "$out" + +out="$(run_fetch_artifact release felis-image-game-linux-amd64.tar)" +expect "a release file is downloaded into the cache once it matches" "FILE $adir/cache/felis-image-game-linux-amd64.tar" "$out" +[ ! -e "$adir/cache/felis-image-game-linux-amd64.tar.partial" ] && echo "PASS the download's partial file is gone" \ + || { echo "FAIL: the partial download was left behind"; fails=$((fails + 1)); } +[ "$(stat -c %a "$adir/cache" 2>/dev/null || stat -f %Lp "$adir/cache")" = 700 ] && echo "PASS the cache is root's alone" \ + || { echo "FAIL: the artifact cache is not 0700"; fails=$((fails + 1)); } +out="$(run_fetch_artifact release felis-image-game-linux-amd64.tar)" +case "$out" in *DOWNLOAD*) echo "FAIL: a cached file that still matches was downloaded again"; fails=$((fails + 1)) ;; *) echo "PASS a cached file that still matches is reused" ;; esac +printf 'tampered\n' > "$adir/cache/felis-image-game-linux-amd64.tar" +out="$(run_fetch_artifact release felis-image-game-linux-amd64.tar)" +expect "a cached file that no longer matches is fetched again" "DOWNLOAD felis-image-game-linux-amd64.tar" "$out" +expect "and the fresh copy is used" "the game images" "$out" +printf 'a swapped asset\n' > "$adir/release/felis-velocity.jar" +out="$(run_fetch_artifact release felis-velocity.jar)" +expect "a download that does not match is refused" "downloaded felis-velocity.jar hashes to $(sha "$adir/release/felis-velocity.jar"), but release v9.9.9's SHA256SUMS says" "$out" +[ ! -e "$adir/cache/felis-velocity.jar" ] && [ ! -e "$adir/cache/felis-velocity.jar.partial" ] \ + && echo "PASS a refused download leaves nothing in the cache" \ + || { echo "FAIL: a refused download was kept"; fails=$((fails + 1)); } +rm -f "$adir/release/felis-velocity.jar" +out="$(run_fetch_artifact release felis-velocity.jar)" +expect "a release without the asset is refused" "could not download felis-velocity.jar from release v9.9.9" "$out" +[ "$(printf '%s\n' "$out" | grep -c '^WARN')" = 1 ] && echo "PASS a failed download is reported once, as a failed download" \ + || { echo "FAIL: a failed download went on to be checked: $out"; fails=$((fails + 1)); } + +# From FELIS_ARTIFACT_DIR nothing is ever built, so an unusable asset stops the install; from +# a release it falls back, with a warning. +unblock="$(bsfn artifact_unusable)" +out="$(ARTIFACT_MODE=dir bash -c 'die() { echo "DIE: $*"; exit 1; }; warn() { echo "WARN: $*"; }; '"$unblock"'; artifact_unusable "no plugin" "building it"; echo GOES ON')" +expect "FELIS_ARTIFACT_DIR stops on an unusable asset" "DIE: FELIS_ARTIFACT_DIR: no plugin" "$out" +case "$out" in *"GOES ON"*) echo "FAIL: FELIS_ARTIFACT_DIR went on without an asset"; fails=$((fails + 1)) ;; esac +out="$(ARTIFACT_MODE=release bash -c 'die() { echo "DIE: $*"; exit 1; }; warn() { echo "WARN: $*"; }; '"$unblock"'; artifact_unusable "no plugin" "building it"; echo GOES ON')" +expect "a release's unusable asset falls back" "WARN: no plugin; building it" "$out" +expect "and the install goes on" "GOES ON" "$out" + +# --- the image listing ---------------------------------------------------------------------- +llblock="$(bsfn load_release_listing)" +d1="sha256:$(printf 'm' | sha256sum | cut -d' ' -f1)" +d2="sha256:$(printf 'c' | sha256sum | cut -d' ' -f1)" +good="felis-image-game-linux-amd64.tar limbo registry.felis.svc:5000/felis/limbo:demo $d1 $d2 +felis-image-base-linux-amd64.tar registry docker.io/library/registry@$d1 $d1 $d2" +run_listing() { # content + printf '%s\n' "$1" > "$adir/listing" + bash -c "$llblock"' + if load_release_listing "$0" amd64; then printf "%s" "$RELEASE_LISTING"; echo LOADED; else echo REFUSED; fi' "$adir/listing" +} +expect "a well-formed listing is loaded" "LOADED" "$(run_listing "$good")" +expect "a listing for another architecture is refused" "REFUSED" "$(run_listing "$(printf '%s\n' "$good" | sed 's/amd64/arm64/')")" +expect "a line with a sixth field is refused" "REFUSED" "$(run_listing "$good extra")" +expect "a line missing its config digest is refused" "REFUSED" "$(run_listing "felis-image-game-linux-amd64.tar limbo registry.felis.svc:5000/felis/limbo:demo $d1")" +expect "a role Felis does not know is refused" "REFUSED" "$(run_listing "felis-image-game-linux-amd64.tar miner x/y:z $d1 $d2")" +expect "a role listed twice is refused" "REFUSED" "$(run_listing "$good +felis-image-game-linux-amd64.tar limbo other/limbo:demo $d1 $d2")" +expect "a short digest is refused" "REFUSED" "$(run_listing "felis-image-game-linux-amd64.tar limbo x/limbo:demo sha256:abc $d2")" +expect "a name with shell characters is refused" "REFUSED" "$(run_listing "felis-image-game-linux-amd64.tar limbo x/limbo:\$(id) $d1 $d2")" +expect "a bundle outside the felis-image-* names is refused" "REFUSED" "$(run_listing "../../etc/shadow limbo x/limbo:demo $d1 $d2")" +expect "an empty listing is refused" "REFUSED" "$(run_listing "")" + +# --- importing a release's images into containerd -------------------------------------------- +# ctr is a fake containerd: `images ls` prints the refs in $adir/ctr (with the header row the +# real one has), `images import` adds what the imported bundle's .names file holds, and +# `images tag` adds a name. +ilblock="$(bsfn artifact_unusable; bsfn role_image; bsfn role_fallback; bsfn role_prebuilt; bsfn image_repo; bsfn image_present; bsfn pinned_image_ref; bsfn import_release_images)" +fdig="sha256:$(printf 'felis' | sha256sum | cut -d' ' -f1)" +gdig="sha256:$(printf 'limbo' | sha256sum | cut -d' ' -f1)" +rdig="sha256:$(printf 'registry' | sha256sum | cut -d' ' -f1)" +cdig="sha256:0123456789ab$(printf 'cfg' | sha256sum | cut -c13-64)" +ridx="sha256:$(printf 'index' | sha256sum | cut -d' ' -f1)" +listing="felis-image-felis-linux-amd64.tar felis registry.felis.svc:5000/felis/felis:v9.9.9 $fdig $cdig +felis-image-game-linux-amd64.tar limbo registry.felis.svc:5000/felis/limbo:demo $gdig $cdig +felis-image-base-linux-amd64.tar registry docker.io/library/registry@$ridx $rdig $cdig" +run_import_release() { # mode ctr-refs FELIS_IMAGE-or-empty roles... + ir_mode="$1" ir_refs="$2" ir_fimg="${3:-registry.felis.svc:5000/felis/felis:v9.9.9}" + shift 3 + MODE="$ir_mode" REFS="$ir_refs" FIMG="$ir_fimg" A="$adir" LISTING="$listing" \ + FD="$fdig" GD="$gdig" RD="$rdig" RIDX="$ridx" bash -c ' + set -Eeuo pipefail + die() { echo "DIE: $*"; exit 1; } + warn() { echo "WARN: $*"; } + log() { :; } + ok() { echo "OK: $*"; } + printf "%s\n" "$REFS" > "$A/ctr" + k3s_cmd() { + shift + case "$2" in + ls) echo "REF TYPE DIGEST SIZE PLATFORMS LABELS"; awk "NF { print \$1, \"application/vnd.oci.image.manifest.v1+json\", \$2, \"25.3 MiB\", \"linux/amd64\", \"-\" }" "$A/ctr" ;; + import) echo "IMPORT ${3##*/}" >&2; cat "$3.names" >> "$A/ctr" ;; + tag) echo "TAG $4 $5" >&2; awk -v s="$4" -v t="$5" "\$1 == s { print t, \$2 }" "$A/ctr" >> "$A/ctr" ;; + esac + } + artifact_fetch() { echo "FETCH $1" >&2; ARTIFACT_FILE="$A/bundles/$1"; [ -f "$ARTIFACT_FILE" ]; } + release_listing() { RELEASE_LISTING="$LISTING"; } + ARTIFACT_MODE="$MODE" PREBUILT_ROLES=" " RELEASE_IMAGES="" LIMBO_IMAGE_ID="" LOBBY_IMAGE_ID="" + FELIS_IMAGE="$FIMG" FELIS_LIMBO_IMAGE=registry.felis.svc:5000/felis/limbo:demo + FELIS_LOBBY_IMAGE=registry.felis.svc:5000/felis/lobby:demo FELIS_PAPER_IMAGE=registry.felis.svc:5000/felis/paper:demo + REGISTRY_IMAGE="docker.io/library/registry:2.8.3@$RIDX" POSTGRES_IMAGE="docker.io/library/postgres:18.6-trixie@$RIDX" + '"$ilblock"' + import_release_images "$@" + echo "PREBUILT[$PREBUILT_ROLES] LIMBO_ID[$LIMBO_IMAGE_ID]" + printf "%s" "$RELEASE_IMAGES"' x "$@" 2>&1 +} +mkdir -p "$adir/bundles" +: > "$adir/bundles/felis-image-felis-linux-amd64.tar" +printf '%s %s\n' "registry.felis.svc:5000/felis/felis:v9.9.9" "$fdig" "registry.felis.svc:5000/felis/felis@$fdig" "$fdig" \ + > "$adir/bundles/felis-image-felis-linux-amd64.tar.names" +: > "$adir/bundles/felis-image-game-linux-amd64.tar" +printf '%s %s\n' "registry.felis.svc:5000/felis/limbo:demo" "$gdig" > "$adir/bundles/felis-image-game-linux-amd64.tar.names" +: > "$adir/bundles/felis-image-base-linux-amd64.tar" +printf '%s %s\n' "docker.io/library/registry@$ridx" "$rdig" > "$adir/bundles/felis-image-base-linux-amd64.tar.names" + +out="$(run_import_release release "" "" felis registry)" +expect "a fresh node imports the bundles holding the images it lacks" "IMPORT felis-image-felis-linux-amd64.tar" "$out" +expect "the base bundle too" "IMPORT felis-image-base-linux-amd64.tar" "$out" +case "$out" in *"felis-image-game"*) echo "FAIL: a bundle no asked-for image is in was fetched"; fails=$((fails + 1)) ;; *) echo "PASS only the bundles holding the asked-for images are fetched" ;; esac +expect "each imported image is recorded as the release's" "PREBUILT[ felis registry ]" "$out" +expect "with the line push_release_image reads" "felis felis-image-felis-linux-amd64.tar registry.felis.svc:5000/felis/felis:v9.9.9 registry.felis.svc:5000/felis/felis:v9.9.9 $fdig $cdig" "$out" + +out="$(run_import_release release "registry.felis.svc:5000/felis/felis:v9.9.9 $fdig +docker.io/library/registry@$ridx $ridx" "" felis registry)" +case "$out" in *FETCH*|*IMPORT*) echo "FAIL: images containerd already holds were fetched again"; fails=$((fails + 1)) ;; *) echo "PASS a rerun fetches nothing containerd already holds" ;; esac +expect "a digest name CRI pulled counts as the image" "PREBUILT[ felis registry ]" "$out" + +out="$(run_import_release release "registry.felis.svc:5000/felis/felis:v9.9.9 sha256:$(printf old | sha256sum | cut -d' ' -f1)" "" felis)" +expect "a tag naming another build is imported over" "IMPORT felis-image-felis-linux-amd64.tar" "$out" + +out="$(run_import_release release "" "registry.felis.svc:5000/felis/felis:custom" felis)" +expect "a FELIS_IMAGE set to another name gets that name on the release's image" "TAG registry.felis.svc:5000/felis/felis:v9.9.9 registry.felis.svc:5000/felis/felis:custom" "$out" +expect "and its digest name, for a pinned pull" "TAG registry.felis.svc:5000/felis/felis:v9.9.9 registry.felis.svc:5000/felis/felis@$fdig" "$out" +expect "and counts as the release's" "PREBUILT[ felis ]" "$out" + +out="$(run_import_release release "" "" limbo)" +expect "a game image's config digest is the build the login server runs" "LIMBO_ID[$cdig]" "$out" + +out="$(run_import_release release "" "" paper)" +expect "a role the release does not list falls back alone" "WARN: the release lists no paper image; building it on this host instead" "$out" +expect "and is not recorded as the release's" "PREBUILT[ ]" "$out" +[ "$(printf '%s\n' "$out" | grep -c '^WARN')" = 1 ] && echo "PASS a role the release does not list is reported once" \ + || { echo "FAIL: a role the release does not list went on to be looked for in containerd: $out"; fails=$((fails + 1)); } +out="$(run_import_release dir "" "" paper)" +expect "from FELIS_ARTIFACT_DIR a missing role stops the install" "DIE: FELIS_ARTIFACT_DIR: the release lists no paper image" "$out" + +out="$(run_import_release release "" "" postgres)" +expect "a base image the release does not list falls back to Docker Hub" "WARN: the release lists no postgres image; k3s pulls it from Docker Hub instead" "$out" +listing="$(printf '%s\n' "$listing" | sed "s|docker.io/library/registry@$ridx|docker.io/library/registry@sha256:$(printf other | sha256sum | cut -d' ' -f1)|")" +out="$(run_import_release release "" "" registry)" +expect "the release's registry must be the one this installer pins" "the release's registry image is docker.io/library/registry@sha256:$(printf other | sha256sum | cut -d' ' -f1), but this installer runs docker.io/library/registry@$ridx" "$out" +expect "and falls back to Docker Hub" "k3s pulls it from Docker Hub instead" "$out" +case "$out" in *IMPORT*) echo "FAIL: a refused base image was imported"; fails=$((fails + 1)) ;; *) echo "PASS a refused base image is not imported" ;; esac + +rm -f "$adir/bundles/felis-image-game-linux-amd64.tar.names" +: > "$adir/bundles/felis-image-game-linux-amd64.tar.names" +out="$(run_import_release release "" "" limbo)" +expect "a bundle that does not hold the image it is listed for falls back" "WARN: k3s containerd holds no registry.felis.svc:5000/felis/limbo:demo from felis-image-game-linux-amd64.tar; building it on this host instead" "$out" +expect "and records nothing" "PREBUILT[ ]" "$out" + +out="$(run_import_release "" "" "" felis limbo)" +case "$out" in *FETCH*|*IMPORT*|*WARN*) echo "FAIL: a source build looked at release bundles"; fails=$((fails + 1)) ;; *) echo "PASS a source build imports nothing from a release" ;; esac + +# The listing is read once per run, and a bad one is reported once. +rlblock="$(bsfn release_listing)" +run_rl() { # listing-content + printf '%s\n' "$1" > "$adir/rl.txt" + A="$adir" bash -c ' + warn() { echo "WARN: $*"; } + die() { echo "DIE: $*"; exit 1; } + felis_asset_arch() { echo amd64; } + artifact_fetch() { echo "FETCH $1"; ARTIFACT_FILE="$A/rl.txt"; } + '"$unblock"' + '"$llblock"' + '"$rlblock"' + ARTIFACT_MODE=release RELEASE_LISTING="" RELEASE_LISTING_STATE="" + release_listing && echo FIRST-OK + release_listing && echo SECOND-OK + true' +} +out="$(run_rl "$good")" +[ "$(printf '%s\n' "$out" | grep -c FETCH)" = 1 ] && expect "a good listing serves every lookup" "FIRST-OK +SECOND-OK" "$out" \ + || { echo "FAIL: the listing was fetched more than once: $out"; fails=$((fails + 1)); } +out="$(run_rl "junk")" +[ "$(printf '%s\n' "$out" | grep -c 'WARN:')" = 1 ] && [ "$(printf '%s\n' "$out" | grep -c FETCH)" = 1 ] \ + && expect "a malformed listing is reported once and trusted nowhere" "is malformed; building its images on this host instead" "$out" \ + || { echo "FAIL: a malformed listing was reported or fetched more than once: $out"; fails=$((fails + 1)); } +case "$out" in *-OK*) echo "FAIL: a malformed listing was used"; fails=$((fails + 1)) ;; esac + +# --- pushing a release image into the platform registry ---------------------------------------- +prblock="$(bsfn push_release_image)" +run_push_release() { # role registry-digest-for-tag registry-digest-for-version-tag [MC_VERSION] + REG_TAG="$2" REG_VER="$3" MCV="${4-26.2}" LINE="$1 felis-image-game-linux-amd64.tar registry.felis.svc:5000/felis/$1:demo registry.felis.svc:5000/felis/$1:demo $gdig $cdig" bash -c ' + die() { echo "DIE: $*"; exit 1; } + warn() { echo "WARN: $*"; } + log() { :; } + ok() { echo "OK: $*"; } + registry_manifest_digest() { case "$1" in *:demo) echo "$REG_TAG" ;; *) echo "$REG_VER" ;; esac; } + artifact_fetch() { ARTIFACT_FILE="/cache/$1"; } + felis() { echo "PUSH-IMAGE user=$FELIS_REGISTRY_USERNAME pass=$FELIS_REGISTRY_PASSWORD $*" >&2; echo "$gdig"; } + HOST_BIN=felis REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 + REGISTRY_PLATFORM_TOKEN=tok MC_VERSION="$MCV" RELEASE_IMAGES="$LINE" + '"$prblock"' + push_release_image "${LINE%% *}"' 2>&1 +} +out="$(run_push_release limbo "" "")" +expect "a release image is pushed from its bundle by name, as the platform principal" \ + "PUSH-IMAGE user=platform pass=tok push-image --tar /cache/felis-image-game-linux-amd64.tar --image registry.felis.svc:5000/felis/limbo:demo --ref 127.0.0.1:5000/felis/limbo:demo" "$out" +expect "and under its Minecraft version and config digest" "--ref 127.0.0.1:5000/felis/limbo:26.2-0123456789ab" "$out" +out="$(run_push_release limbo "$gdig" "")" +case "$out" in *"--ref 127.0.0.1:5000/felis/limbo:demo"*) echo "FAIL: a tag already naming the image was pushed again"; fails=$((fails + 1)) ;; *) echo "PASS a tag already naming the image is left as it is" ;; esac +expect "while a missing version tag is still pushed" "--ref 127.0.0.1:5000/felis/limbo:26.2-0123456789ab" "$out" +out="$(run_push_release limbo "sha256:$(printf other | sha256sum | cut -d' ' -f1)" "$gdig")" +expect "a tag naming another build is pushed over" "--ref 127.0.0.1:5000/felis/limbo:demo" "$out" +out="$(run_push_release felis "" "")" +case "$out" in *"26.2-"*) echo "FAIL: the control-plane image got a Minecraft version tag"; fails=$((fails + 1)) ;; *) echo "PASS only game images get a version tag" ;; esac +out="$(run_push_release limbo "" "" "")" +case "$out" in *"-0123456789ab"*) echo "FAIL: a version tag was made without a Minecraft version"; fails=$((fails + 1)) ;; *) echo "PASS no Minecraft version, no version tag" ;; esac + +rmblock="$(bsfn registry_manifest_digest)" +curlargs="$(mktemp)" +out="$(CA="$curlargs" bash -c 'curl() { printf "%s\n" "$*" > "$CA"; printf "HTTP/1.1 200 OK\r\nContent-Type: x\r\nDocker-Content-Digest: sha256:abc\r\n\r\n"; } +'"$rmblock"' +registry_manifest_digest 127.0.0.1:5000/felis/limbo:26.2-0123')" +expect "the registry's digest for a tag is read off a HEAD" "-fsSI --max-time 30" "$(cat "$curlargs")" +expect "from the tag's manifest URL" "http://127.0.0.1:5000/v2/felis/limbo/manifests/26.2-0123" "$(cat "$curlargs")" +expect "asking for every manifest kind a registry may hold" "application/vnd.docker.distribution.manifest.list.v2+json" "$(cat "$curlargs")" +rm -f "$curlargs" +[ "$out" = "sha256:abc" ] && echo "PASS the digest comes back without its carriage return" \ + || { echo "FAIL: registry_manifest_digest printed: $out"; fails=$((fails + 1)); } +[ -z "$(bash -c 'curl() { return 22; }; '"$rmblock"'; registry_manifest_digest 127.0.0.1:5000/felis/x:demo')" ] \ + && echo "PASS a tag the registry lacks has no digest" || { echo "FAIL: a missing tag printed a digest"; fails=$((fails + 1)); } + +# --- the binary from FELIS_ARTIFACT_DIR ----------------------------------------------------- +iabblock="$(bsfn load_artifact_sums; bsfn artifact_sum; bsfn install_artifact_binary)" +mkdir -p "$adir/abin/dir" "$adir/abin/bin" +run_artifact_binary() { # binary-script + printf '%s\n' "$1" > "$adir/abin/dir/felis-linux-amd64" + printf '%s felis-linux-amd64\n' "$(sha "$adir/abin/dir/felis-linux-amd64")" > "$adir/abin/dir/SHA256SUMS" + [ -z "${TAMPER:-}" ] || printf 'x\n' >> "$adir/abin/dir/felis-linux-amd64" + A="$adir/abin" bash -c ' + set -Eeuo pipefail + die() { echo "DIE: $*"; exit 1; } + ok() { echo "OK: $*"; } + remember_temp() { :; } + keep_previous_host_binary() { echo KEEP; } + felis_asset_arch() { echo amd64; } + FELIS_ARTIFACT_DIR="$A/dir" HOST_BIN="$A/bin/felis" ARTIFACT_MODE="" ARTIFACT_SUMS="" HAVE_PREBUILT_BINARY="" FELIS_VERSION="" + '"$iabblock"' + install_artifact_binary + echo "VERSION[$FELIS_VERSION] PREBUILT[$HAVE_PREBUILT_BINARY] MODE[$ARTIFACT_MODE]"' 2>&1 +} +out="$(run_artifact_binary '#!/bin/sh +echo "felis v9.9.9"')" +expect "the directory's binary is installed and names the version" "VERSION[v9.9.9] PREBUILT[1] MODE[dir]" "$out" +[ -x "$adir/abin/bin/felis" ] && echo "PASS the binary lands at HOST_BIN" || { echo "FAIL: no binary at HOST_BIN"; fails=$((fails + 1)); } +out="$(run_artifact_binary '#!/bin/sh +echo "felis v9.9.9"')" +case "$out" in *KEEP*) echo "FAIL: the same binary was replaced (and its previous copy overwritten)"; fails=$((fails + 1)) ;; *) echo "PASS the same binary is left in place" ;; esac +out="$(TAMPER=1 run_artifact_binary '#!/bin/sh +echo "felis v9.9.9"')" +expect "a binary that does not match SHA256SUMS stops the install" "DIE: FELIS_ARTIFACT_DIR: felis-linux-amd64 does not match SHA256SUMS" "$out" +out="$(run_artifact_binary '#!/bin/sh +exit 1')" +expect "a binary that cannot say its version stops the install" "DIE: FELIS_ARTIFACT_DIR: felis-linux-amd64 reports 'nothing' as its version" "$out" +rm -f "$adir/abin/dir/SHA256SUMS" +out="$(A="$adir/abin" bash -c 'die() { echo "DIE: $*"; exit 1; }; remember_temp() { :; }; felis_asset_arch() { echo amd64; } +FELIS_ARTIFACT_DIR="$A/dir" ARTIFACT_MODE="" ARTIFACT_SUMS="" +'"$iabblock"' +install_artifact_binary' 2>&1)" +expect "a directory without SHA256SUMS stops the install" "DIE: FELIS_ARTIFACT_DIR: $adir/abin/dir holds no SHA256SUMS" "$out" + +# --- which release the images come from ------------------------------------------------------- +srblock="$(bsfn select_release_artifacts)" +run_select() { # binary-version sums-available(0|1) [ARTIFACT_MODE] [HAVE_PREBUILT_BINARY] + printf '#!/bin/sh\necho "felis %s"\n' "$1" > "$adir/selbin"; chmod +x "$adir/selbin" + SUMS_OK="$2" MODE="${3:-}" PRE="${4-1}" B="$adir/selbin" bash -c ' + ok() { echo "OK: $*"; } + warn() { echo "WARN: $*"; } + load_artifact_sums() { echo "SUMS $ARTIFACT_TAG"; [ "$SUMS_OK" = 1 ]; } + ARTIFACT_MODE="$MODE" ARTIFACT_TAG="" HAVE_PREBUILT_BINARY="$PRE" HOST_BIN="$B" + '"$srblock"' + select_release_artifacts + echo "MODE[$ARTIFACT_MODE] TAG[$ARTIFACT_TAG]"' +} +expect "a release binary takes its images from that release" "MODE[release] TAG[v9.9.9]" "$(run_select v9.9.9 1)" +expect "a prerelease tag too" "MODE[release] TAG[v9.9.9-rc.1]" "$(run_select v9.9.9-rc.1 1)" +out="$(run_select v9.9.9 0)" +expect "a release without SHA256SUMS builds its images here" "MODE[] TAG[]" "$out" +expect "and says what that costs" "this installs Docker and needs about 8 GiB more" "$out" +out="$(run_select v0.0.0+gabc1234 1)" +expect "a dev build names no release" "MODE[] TAG[]" "$out" +case "$out" in *SUMS*) echo "FAIL: a dev build looked up a release"; fails=$((fails + 1)) ;; *) echo "PASS a dev build looks up no release" ;; esac +case "$(run_select dev 1)" in *SUMS*) echo "FAIL: an unstamped build looked up a release"; fails=$((fails + 1)) ;; *) echo "PASS an unstamped build looks up no release" ;; esac +expect "a source build takes nothing from a release" "MODE[] TAG[]" "$(run_select v9.9.9 1 "" "")" +expect "FELIS_ARTIFACT_DIR stays the source" "MODE[dir] TAG[]" "$(run_select v9.9.9 1 dir)" + +# --- the game stack and the Velocity plugin -------------------------------------------------- +gsblock="$(bsfn build_game_stack; bsfn role_prebuilt; bsfn stop_docker)" +run_game_stack() { # PREBUILT_ROLES-after-import FELIS_GAME_STACK [ARTIFACT_MODE] + AFTER="$1" STACK="$2" MODE="${3-release}" bash -c ' + ok() { :; } + game_stack_source() { :; } + resolve_game_jars() { :; } + import_release_images() { echo "IMPORT $*"; PREBUILT_ROLES="$AFTER"; } + ensure_docker() { echo ENSURE; DOCKER_INSTALLED=1; } + build_game_image() { echo "BUILD $1"; } + install_velocity_plugin() { echo PLUGIN; } + systemctl() { echo "SYSTEMCTL $*"; } + PREBUILT_ROLES=" " DOCKER_INSTALLED="" FELIS_GAME_STACK="$STACK" ARTIFACT_MODE="$MODE" + '"$gsblock"' + build_game_stack' +} +out="$(run_game_stack " limbo lobby paper " pinned)" +expect "the pinned stack comes from the release" "IMPORT limbo lobby paper" "$out" +case "$out" in *BUILD*|*ENSURE*|*SYSTEMCTL*) echo "FAIL: a release game stack built or touched docker: $out"; fails=$((fails + 1)) ;; *) echo "PASS a release game stack needs no docker" ;; esac +out="$(run_game_stack " limbo " pinned)" +expect "only what the release did not supply is built" "ENSURE +BUILD lobby +BUILD paper +PLUGIN +SYSTEMCTL stop docker docker.socket" "$out" +out="$(run_game_stack " limbo lobby paper " latest)" +case "$out" in *IMPORT*) echo "FAIL: FELIS_GAME_STACK=latest took the release's pinned images"; fails=$((fails + 1)) ;; *) echo "PASS FELIS_GAME_STACK=latest takes nothing from the release" ;; esac +expect "and builds all three" "BUILD limbo +BUILD lobby +BUILD paper" "$out" + +vpblock="$(bsfn install_velocity_plugin; bsfn artifact_unusable)" +run_plugin() { # ARTIFACT_MODE fetch-ok(0|1) + MODE="$1" FOK="$2" bash -c ' + die() { echo "DIE: $*"; exit 1; } + warn() { echo "WARN: $*"; } + ok() { echo "OK: $*"; } + artifact_fetch() { ARTIFACT_FILE=/cache/felis-velocity.jar; [ "$FOK" = 1 ]; } + prepare_velocity_layout() { echo LAYOUT; } + install_if_changed() { echo "INSTALL $*"; } + ensure_docker() { echo ENSURE; } + build_velocity_plugin() { echo BUILD; } + ARTIFACT_MODE="$MODE" VELOCITY_DIR=/opt/felis/velocity + '"$vpblock"' + install_velocity_plugin' +} +out="$(run_plugin release 1)" +expect "the release's plugin is put in place" "LAYOUT +INSTALL /cache/felis-velocity.jar /opt/felis/velocity/plugins/felis-velocity.jar 0644 root root" "$out" +case "$out" in *BUILD*|*ENSURE*) echo "FAIL: a release plugin was built too"; fails=$((fails + 1)) ;; *) echo "PASS a release plugin is not built" ;; esac +out="$(run_plugin release 0)" +expect "an unusable release plugin is built here" "WARN: the release's felis-velocity.jar cannot be used; building it on this host instead +ENSURE +BUILD" "$out" +expect "from FELIS_ARTIFACT_DIR it stops the install" "DIE: FELIS_ARTIFACT_DIR: the release's felis-velocity.jar cannot be used" "$(run_plugin dir 0)" +expect "a source build builds the plugin" "ENSURE +BUILD" "$(run_plugin "" 0)" + +# --- k3s's own images from its GitHub release ---------------------------------------------------- +kablock="$(bsfn stage_k3s_airgap_images)" +mkdir -p "$adir/k3simg" "$adir/k3srel" +printf 'k3s images\n' > "$adir/k3srel/k3s-airgap-images-amd64.tar.zst" +run_airgap() { # sums-file-content + printf '%s\n' "$1" > "$adir/k3srel/sha256sum-amd64.txt" + A="$adir" bash -c ' + set -Eeuo pipefail + warn() { echo "WARN: $*"; } + ok() { echo "OK: $*"; } + log() { :; } + remember_temp() { :; } + felis_asset_arch() { echo amd64; } + curl() { + local out="" url + while [ "$#" -gt 0 ]; do case "$1" in -o) out="$2"; shift 2 ;; *) url="$1"; shift ;; esac; done + echo "CURL ${url##*/}" >&2 + [ -f "$A/k3srel/${url##*/}" ] || return 22 + if [ -n "$out" ]; then cp "$A/k3srel/${url##*/}" "$out"; else cat "$A/k3srel/${url##*/}"; fi + } + FELIS_K3S_VERSION=v1.36.4+k3s1 K3S_IMAGES_DIR="$A/k3simg" + '"$kablock"' + stage_k3s_airgap_images + echo STAGED-OK' 2>&1 +} +ksum="$(sha "$adir/k3srel/k3s-airgap-images-amd64.tar.zst")" +out="$(run_airgap "$ksum k3s-airgap-images-amd64.tar.zst")" +expect "k3s's images are downloaded where k3s imports them" "OK: staged k3s v1.36.4+k3s1's images" "$out" +cmp -s "$adir/k3simg/k3s-airgap-images-amd64.tar.zst" "$adir/k3srel/k3s-airgap-images-amd64.tar.zst" \ + && echo "PASS the staged tarball is the release's" || { echo "FAIL: the tarball was not staged"; fails=$((fails + 1)); } +[ "$(ls -A "$adir/k3simg")" = k3s-airgap-images-amd64.tar.zst ] && echo "PASS no temp file is left where k3s reads" \ + || { echo "FAIL: a temp file was left in the images directory: $(ls -A "$adir/k3simg")"; fails=$((fails + 1)); } +out="$(run_airgap "$ksum k3s-airgap-images-amd64.tar.zst")" +case "$out" in *"CURL k3s-airgap-images-amd64.tar.zst"*) echo "FAIL: staged images were downloaded again"; fails=$((fails + 1)) ;; *) echo "PASS staged images are not downloaded again" ;; esac +rm -f "$adir/k3simg/k3s-airgap-images-amd64.tar.zst" +out="$(run_airgap "$(printf '%064d' 0) k3s-airgap-images-amd64.tar.zst")" +expect "a tarball that does not match k3s's sums is refused" "WARN: k3s-airgap-images-amd64.tar.zst hashes to $ksum" "$out" +expect "and the install goes on without it" "STAGED-OK" "$out" +[ -z "$(ls -A "$adir/k3simg")" ] && echo "PASS a refused tarball leaves nothing for k3s to import" \ + || { echo "FAIL: a refused tarball was left: $(ls -A "$adir/k3simg")"; fails=$((fails + 1)); } +out="$(run_airgap "$ksum k3s-airgap-images-arm64.tar.zst")" +expect "a sums file that does not list the tarball stages nothing" "WARN: k3s v1.36.4+k3s1 lists no sha256 for k3s-airgap-images-amd64.tar.zst" "$out" +case "$out" in *"CURL k3s-airgap-images-amd64.tar.zst"*) echo "FAIL: an unlisted tarball was downloaded"; fails=$((fails + 1)) ;; *) echo "PASS an unlisted tarball is not downloaded" ;; esac + +# --- one release lookup per tag ------------------------------------------------------------------ +rjblock="$(bsfn load_release_json; bsfn github_asset_id)" +out="$(bash -c ' + github_api() { echo "API $1" >&2; printf "{\"assets\":[{\"url\":\"https://api.github.com/repos/o/r/releases/assets/11\",\"name\":\"SHA256SUMS\"},{\"url\":\"https://api.github.com/repos/o/r/releases/assets/12\",\"name\":\"felis-velocity.jar\"}]}"; } + repo_slug() { echo o/r; } + RELEASE_JSON_TAG="" RELEASE_JSON="" + '"$rjblock"' + load_release_json v9.9.9 + github_asset_id v9.9.9 SHA256SUMS; github_asset_id v9.9.9 felis-velocity.jar' 2>&1)" +[ "$(printf '%s\n' "$out" | grep -c '^API')" = 1 ] && echo "PASS one release is looked up once" \ + || { echo "FAIL: the release was looked up more than once: $out"; fails=$((fails + 1)); } +expect "and every asset is found in it" "11 +12" "$out" + +# --- FELIS_ARTIFACT_DIR is checked before anything happens ------------------------------------ +vsblock="$(awk '/^ if \[ -n "\$FELIS_ARTIFACT_DIR" \]; then$/ { f = 1 } f { print } f && /^ fi$/ { exit }' "$BS")" +case "$vsblock" in *"FELIS_SKIP_FETCH both"*) ;; *) echo "FAIL: the FELIS_ARTIFACT_DIR checks in validate_settings moved"; exit 1 ;; esac +run_vs() { # FELIS_ARTIFACT_DIR [FELIS_REF_PINNED] + FELIS_ARTIFACT_DIR="$1" FELIS_REF_PINNED="${2:-}" bash -c 'die() { echo "DIE: $*"; exit 1; } +'"$vsblock"' +echo VALID' +} +expect "a relative FELIS_ARTIFACT_DIR is refused" "DIE: FELIS_ARTIFACT_DIR must be an absolute path" "$(run_vs release-assets)" +expect "a FELIS_ARTIFACT_DIR without SHA256SUMS is refused up front" "SHA256SUMS does not exist" "$(run_vs "$adir/nowhere")" +printf 'x y\n' > "$adir/SHA256SUMS" +expect "a FELIS_ARTIFACT_DIR with SHA256SUMS is accepted" "VALID" "$(run_vs "$adir")" +expect "FELIS_ARTIFACT_DIR and FELIS_REF together are refused" "DIE: FELIS_ARTIFACT_DIR and FELIS_REF both name what to install" "$(run_vs "$adir" 1)" +rm -rf "$adir" + # --------------------------------------------------------------------------------------- if [ "$fails" -eq 0 ]; then echo "ALL PASS"