#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # shellcheck source=Scripts/_burrow-flake.sh source "${SCRIPT_DIR}/_burrow-flake.sh" DEFAULT_CONFIG="burrow-forge" DEFAULT_FLAKE="." DEFAULT_LOCATION="hel1" DEFAULT_ARCHITECTURE="x86" DEFAULT_TOKEN_FILE="${REPO_ROOT}/intake/hetzner-api-token.txt" CONFIG="${HCLOUD_IMAGE_CONFIG:-${DEFAULT_CONFIG}}" FLAKE="${HCLOUD_IMAGE_FLAKE:-${DEFAULT_FLAKE}}" LOCATION="${HCLOUD_IMAGE_LOCATION:-${DEFAULT_LOCATION}}" ARCHITECTURE="${HCLOUD_IMAGE_ARCHITECTURE:-${DEFAULT_ARCHITECTURE}}" TOKEN_FILE="${HCLOUD_TOKEN_FILE:-${DEFAULT_TOKEN_FILE}}" DESCRIPTION="${HCLOUD_IMAGE_DESCRIPTION:-}" UPLOAD_SERVER_TYPE="${HCLOUD_IMAGE_UPLOAD_SERVER_TYPE:-}" UPLOAD_VERBOSE="${HCLOUD_IMAGE_UPLOAD_VERBOSE:-0}" ARTIFACT_PATH_INPUT="" OUTPUT_HASH="" NO_UPDATE=0 BUILDER_SPEC="${HCLOUD_IMAGE_BUILDER_SPEC:-}" EXTRA_LABELS=() NIX_BUILD_FLAGS=() BURROW_FLAKE_TMPDIRS=() LOCAL_STORE_DIR="" usage() { cat <<'EOF' Usage: Scripts/hcloud-upload-nixos-image.sh [options] Build a raw Burrow NixOS image and upload it into Hetzner Cloud as a snapshot. Options: --config images.-raw output to build (default: burrow-forge) --flake Flake path to build from (default: .) --location Hetzner location for the temporary upload server (default: hel1) --architecture CPU architecture of the image (default: x86) --server-type Hetzner server type for the temporary upload server --token-file Hetzner API token file (default: intake/hetzner-api-token.txt) --artifact-path Prebuilt raw image artifact to upload directly --output-hash Stable hash label for --artifact-path uploads --builder-spec Complete builders string passed to nix build --description Description for the resulting snapshot --upload-verbose Pass -v N times to hcloud-upload-image --label key=value Extra Hetzner image label (repeatable) --nix-flag Extra argument passed to nix build (repeatable) --no-update Reuse an existing snapshot with the same config/output hash -h, --help Show this help text EOF } while [[ $# -gt 0 ]]; do case "$1" in --config) CONFIG="${2:?missing value for --config}" shift 2 ;; --flake) FLAKE="${2:?missing value for --flake}" shift 2 ;; --location) LOCATION="${2:?missing value for --location}" shift 2 ;; --architecture) ARCHITECTURE="${2:?missing value for --architecture}" shift 2 ;; --server-type) UPLOAD_SERVER_TYPE="${2:?missing value for --server-type}" shift 2 ;; --token-file) TOKEN_FILE="${2:?missing value for --token-file}" shift 2 ;; --artifact-path) ARTIFACT_PATH_INPUT="${2:?missing value for --artifact-path}" shift 2 ;; --output-hash) OUTPUT_HASH="${2:?missing value for --output-hash}" shift 2 ;; --builder-spec) BUILDER_SPEC="${2:?missing value for --builder-spec}" shift 2 ;; --description) DESCRIPTION="${2:?missing value for --description}" shift 2 ;; --upload-verbose) UPLOAD_VERBOSE="${2:?missing value for --upload-verbose}" shift 2 ;; --label) EXTRA_LABELS+=("${2:?missing value for --label}") shift 2 ;; --nix-flag) NIX_BUILD_FLAGS+=("${2:?missing value for --nix-flag}") shift 2 ;; --no-update) NO_UPDATE=1 shift ;; -h|--help) usage exit 0 ;; *) echo "unknown option: $1" >&2 usage >&2 exit 64 ;; esac done cleanup() { burrow_cleanup_flake_tmpdirs if [[ -n "${LOCAL_STORE_DIR}" && -d "${LOCAL_STORE_DIR}" ]]; then rm -rf "${LOCAL_STORE_DIR}" >/dev/null 2>&1 || true fi } trap cleanup EXIT burrow_require_cmd nix burrow_require_cmd curl burrow_require_cmd python3 burrow_require_cmd rsync if [[ ! -f "${TOKEN_FILE}" ]]; then echo "Hetzner API token file not found: ${TOKEN_FILE}" >&2 exit 1 fi HCLOUD_TOKEN="$(tr -d '\r\n' < "${TOKEN_FILE}")" if [[ -z "${HCLOUD_TOKEN}" ]]; then echo "Hetzner API token file is empty: ${TOKEN_FILE}" >&2 exit 1 fi flake_ref="$(burrow_prepare_flake_ref "${FLAKE}")" if [[ -z "${DESCRIPTION}" ]]; then DESCRIPTION="Burrow ${CONFIG} $(date -u +%Y-%m-%dT%H:%M:%SZ)" fi printf 'Building raw image for %s from %s\n' "${CONFIG}" "${flake_ref}" >&2 if [[ -z "${ARTIFACT_PATH_INPUT}" && -n "${BUILDER_SPEC}" && -z "${NIX_BUILD_STORE:-}" ]]; then mkdir -p "${HOME}/.cache/burrow" LOCAL_STORE_DIR="$(mktemp -d "${HOME}/.cache/burrow/local-store-XXXXXX")" fi artifact_path="" compression="" output_hash="${OUTPUT_HASH}" if [[ -n "${ARTIFACT_PATH_INPUT}" ]]; then artifact_path="${ARTIFACT_PATH_INPUT}" if [[ ! -f "${artifact_path}" ]]; then echo "artifact path does not exist: ${artifact_path}" >&2 exit 1 fi compression="$(burrow_detect_compression "${artifact_path}")" if [[ -z "${output_hash}" ]]; then if command -v sha256sum >/dev/null 2>&1; then output_hash="$(sha256sum "${artifact_path}" | awk '{print $1}')" else output_hash="$(shasum -a 256 "${artifact_path}" | awk '{print $1}')" fi fi else nix_build_cmd=( nix --extra-experimental-features "nix-command flakes" build "${flake_ref}#images.${CONFIG}-raw" --no-link --print-out-paths ) if [[ -n "${BUILDER_SPEC}" ]]; then nix_build_cmd+=(--builders "${BUILDER_SPEC}") fi if [[ -n "${NIX_BUILD_STORE:-}" ]]; then nix_build_cmd+=(--store "${NIX_BUILD_STORE}") elif [[ -n "${LOCAL_STORE_DIR}" ]]; then nix_build_cmd+=(--store "${LOCAL_STORE_DIR}") fi if [[ "${#NIX_BUILD_FLAGS[@]}" -gt 0 ]]; then nix_build_cmd+=("${NIX_BUILD_FLAGS[@]}") fi build_output="" if ! build_output="$("${nix_build_cmd[@]}" 2>&1)"; then printf '%s\n' "${build_output}" >&2 exit 1 fi store_path="$(printf '%s\n' "${build_output}" | tail -n1)" if [[ -z "${store_path}" ]]; then echo "nix build did not return a store path" >&2 printf '%s\n' "${build_output}" >&2 exit 1 fi artifact_path="$(burrow_resolve_image_artifact "${store_path}")" compression="$(burrow_detect_compression "${artifact_path}")" output_hash="$(basename "${store_path}")" output_hash="${output_hash%%-*}" fi label_args=( "burrow.nixos-config=${CONFIG}" "burrow.nixos-output-hash=${output_hash}" ) if [[ "${#EXTRA_LABELS[@]}" -gt 0 ]]; then label_args+=("${EXTRA_LABELS[@]}") fi label_csv="$(IFS=,; printf '%s' "${label_args[*]}")" find_existing_image() { HCLOUD_TOKEN="${HCLOUD_TOKEN}" \ BURROW_LABEL_SELECTOR="burrow.nixos-config=${CONFIG},burrow.nixos-output-hash=${output_hash}" \ python3 - <<'PY' import json import os import sys import urllib.parse import urllib.request selector = urllib.parse.quote(os.environ["BURROW_LABEL_SELECTOR"], safe=",=") req = urllib.request.Request( f"https://api.hetzner.cloud/v1/images?type=snapshot&label_selector={selector}", headers={"Authorization": f"Bearer {os.environ['HCLOUD_TOKEN']}"}, ) with urllib.request.urlopen(req, timeout=30) as resp: data = json.load(resp) images = sorted(data.get("images", []), key=lambda item: item.get("created") or "") if images: print(images[-1]["id"]) PY } if [[ "${NO_UPDATE}" -eq 1 ]]; then existing_id="$(find_existing_image || true)" if [[ -n "${existing_id}" ]]; then printf 'Reusing existing Hetzner snapshot %s for %s\n' "${existing_id}" "${CONFIG}" >&2 printf '%s\n' "${existing_id}" exit 0 fi fi uploader_bin="${HCLOUD_UPLOAD_IMAGE_BIN:-}" if [[ -z "${uploader_bin}" ]]; then uploader_build_output="$( nix --extra-experimental-features "nix-command flakes" build \ "${flake_ref}#hcloud-upload-image" \ --no-link \ --print-out-paths 2>&1 )" || { printf '%s\n' "${uploader_build_output}" >&2 exit 1 } uploader_bin="$(printf '%s\n' "${uploader_build_output}" | tail -n1)/bin/hcloud-upload-image" fi if [[ ! -x "${uploader_bin}" ]]; then echo "unable to resolve an executable hcloud-upload-image binary; set HCLOUD_UPLOAD_IMAGE_BIN explicitly" >&2 exit 1 fi upload_cmd=( "${uploader_bin}" ) if [[ "${UPLOAD_VERBOSE}" =~ ^[0-9]+$ ]] && [[ "${UPLOAD_VERBOSE}" -gt 0 ]]; then for _ in $(seq 1 "${UPLOAD_VERBOSE}"); do upload_cmd+=(-v) done fi upload_cmd+=( upload --image-path "${artifact_path}" --location "${LOCATION}" --description "${DESCRIPTION}" --labels "${label_csv}" ) if [[ -n "${UPLOAD_SERVER_TYPE}" ]]; then upload_cmd+=(--server-type "${UPLOAD_SERVER_TYPE}") else upload_cmd+=(--architecture "${ARCHITECTURE}") fi if [[ -n "${compression}" ]]; then upload_cmd+=(--compression "${compression}") fi printf 'Uploading %s to Hetzner Cloud via %s\n' "${artifact_path}" "${uploader_bin}" >&2 HCLOUD_TOKEN="${HCLOUD_TOKEN}" "${upload_cmd[@]}" >&2 image_id="" for _ in $(seq 1 24); do image_id="$(find_existing_image || true)" if [[ -n "${image_id}" ]]; then break fi sleep 5 done if [[ -z "${image_id}" ]]; then echo "failed to locate uploaded Hetzner snapshot after upload completed" >&2 exit 1 fi printf '%s\n' "${image_id}"