#!/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" # shellcheck source=Scripts/_burrow-secrets.sh source "${SCRIPT_DIR}/_burrow-secrets.sh" usage() { cat <<'EOF' Usage: Scripts/provision-forgejo-nsc.sh [options] Generate Burrow forgejo-nsc runtime inputs and refresh the authoritative `secrets/forgejo/*.age` files, optionally refreshing the Namespace token from the currently logged-in namespace account. Options: --host SSH target used to mint the Forgejo PAT. Default: root@git.burrow.net --ssh-key SSH private key for the forge host. Default: secrets/forgejo/agent-ssh-key.age, then intake/ --nsc-bin Override the nsc binary. --no-refresh-token Reuse the existing encrypted Namespace token if it already exists. --token-name Forgejo PAT name prefix (default: forgejo-nsc) --contact-user Forgejo username used for PAT creation (default: contact) --scope-owner Forgejo org/user owner for the default NSC scope (default: hackclub) --scope-name Forgejo repository name for the default NSC scope (default: burrow) -h, --help Show this help text. EOF } HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" NSC_BIN="${NSC_BIN:-}" KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" REFRESH_TOKEN=1 TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}" CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}" SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" BURROW_FLAKE_TMPDIRS=() TMP_DIR="" cleanup() { [[ -n "${TMP_DIR}" ]] && rm -rf "${TMP_DIR}" >/dev/null 2>&1 || true burrow_cleanup_secret_tmpfiles burrow_cleanup_flake_tmpdirs } trap cleanup EXIT while [[ $# -gt 0 ]]; do case "$1" in --host) HOST="${2:?missing value for --host}" shift 2 ;; --ssh-key) SSH_KEY="${2:?missing value for --ssh-key}" shift 2 ;; --nsc-bin) NSC_BIN="${2:?missing value for --nsc-bin}" shift 2 ;; --no-refresh-token) REFRESH_TOKEN=0 shift ;; --token-name) TOKEN_NAME_PREFIX="${2:?missing value for --token-name}" shift 2 ;; --contact-user) CONTACT_USER="${2:?missing value for --contact-user}" shift 2 ;; --scope-owner) SCOPE_OWNER="${2:?missing value for --scope-owner}" shift 2 ;; --scope-name) SCOPE_NAME="${2:?missing value for --scope-name}" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "unknown option: $1" >&2 usage >&2 exit 64 ;; esac done mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" burrow_require_cmd nix burrow_require_cmd ssh burrow_require_cmd python3 SSH_KEY="$( burrow_resolve_secret_file \ "${REPO_ROOT}" \ "${SSH_KEY}" \ "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" \ "${REPO_ROOT}/secrets/forgejo/agent-ssh-key.age" \ "${HOME}/.ssh/agent_at_burrow_net_ed25519" )" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/burrow-forgejo-nsc.XXXXXX")" flake_ref="$(burrow_prepare_flake_ref "${REPO_ROOT}")" if [[ -z "${NSC_BIN}" ]]; then if command -v nsc >/dev/null 2>&1; then NSC_BIN="$(command -v nsc)" else nsc_build_output="$( nix --extra-experimental-features "nix-command flakes" build \ "${flake_ref}#nsc" \ --no-link \ --print-out-paths 2>&1 )" || { printf '%s\n' "${nsc_build_output}" >&2 exit 1 } NSC_BIN="$(printf '%s\n' "${nsc_build_output}" | tail -n1)/bin/nsc" fi fi if [[ ! -x "${NSC_BIN}" ]]; then echo "unable to resolve an executable nsc binary; set NSC_BIN explicitly" >&2 exit 1 fi token_file="${TMP_DIR}/forgejo_nsc_token.txt" dispatcher_out="${TMP_DIR}/forgejo_nsc_dispatcher.yaml" autoscaler_out="${TMP_DIR}/forgejo_nsc_autoscaler.yaml" dispatcher_src="${REPO_ROOT}/services/forgejo-nsc/deploy/dispatcher.yaml" autoscaler_src="${REPO_ROOT}/services/forgejo-nsc/deploy/autoscaler.yaml" token_secret="${REPO_ROOT}/secrets/forgejo/nsc-token.age" dispatcher_secret="${REPO_ROOT}/secrets/forgejo/nsc-dispatcher-config.age" autoscaler_secret="${REPO_ROOT}/secrets/forgejo/nsc-autoscaler-config.age" if [[ "${REFRESH_TOKEN}" -eq 1 ]]; then ssh \ -i "${SSH_KEY}" \ -o IdentitiesOnly=yes \ -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ -o StrictHostKeyChecking=accept-new \ "${HOST}" \ 'sudo -u forgejo-nsc python3 - <<'"'"'PY'"'"' import json from pathlib import Path payload = {} token_json = Path("/var/lib/forgejo-nsc/.config/ns/token.json") if token_json.exists(): data = json.loads(token_json.read_text(encoding="utf-8")) session = str(data.get("session_token", "")).strip() if session: payload["session_token"] = session token_cache = Path("/var/lib/forgejo-nsc/.config/ns/token.cache") if token_cache.exists(): bearer = token_cache.read_text(encoding="utf-8").strip() if bearer: payload["bearer_token"] = bearer if not payload: raise SystemExit("forgejo-nsc host does not have a usable Namespace session") print(json.dumps(payload, indent=2)) PY' > "${token_file}" chmod 600 "${token_file}" elif [[ -f "${token_secret}" ]]; then burrow_decrypt_age_secret_to_temp "${REPO_ROOT}" "${token_secret}" > "${token_file}" fi if [[ -s "${token_file}" ]]; then TOKEN_FILE="${token_file}" python3 - <<'PY' import json import os from pathlib import Path path = Path(os.environ["TOKEN_FILE"]) raw = path.read_text(encoding="utf-8").strip() if not raw: raise SystemExit(0) try: parsed = json.loads(raw) except json.JSONDecodeError: parsed = None if isinstance(parsed, dict): bearer = parsed.get("bearer_token") session = parsed.get("session_token") if isinstance(bearer, str) and bearer.strip(): raise SystemExit(0) if isinstance(session, str) and session.strip(): raise SystemExit(0) path.write_text(json.dumps({"bearer_token": raw}, indent=2) + "\n", encoding="utf-8") PY fi webhook_secret="$(python3 - <<'PY' import secrets print(secrets.token_hex(32)) PY )" token_name="${TOKEN_NAME_PREFIX}-$(date -u +%Y%m%dT%H%M%SZ)" forgejo_pat="$( ssh \ -i "${SSH_KEY}" \ -o IdentitiesOnly=yes \ -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ -o StrictHostKeyChecking=accept-new \ "${HOST}" \ "set -euo pipefail; forgejo_bin=\$(systemctl show -p ExecStart forgejo.service --value | sed -E 's/^\\{ path=([^ ;]+).*/\\1/'); sudo -u forgejo \"\${forgejo_bin}\" --config /var/lib/forgejo/custom/conf/app.ini --custom-path /var/lib/forgejo/custom --work-path /var/lib/forgejo admin user generate-access-token --username '${CONTACT_USER}' --scopes all --raw --token-name '${token_name}'" \ | tr -d '\r\n' )" if [[ -z "${forgejo_pat}" ]]; then echo "failed to mint Forgejo PAT on ${HOST}" >&2 exit 1 fi ssh \ -i "${SSH_KEY}" \ -o IdentitiesOnly=yes \ -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ -o StrictHostKeyChecking=accept-new \ "${HOST}" \ 'bash -s' </tmp/forgejo-provision-org.json <&2 cat /tmp/forgejo-provision-response.json >&2 exit 1 fi fi repo_code="\$(api "\${base_url}/api/v1/repos/\${scope_owner}/\${scope_name}")" if [[ "\${repo_code}" == "404" ]]; then cat >/tmp/forgejo-provision-repo.json <&2 cat /tmp/forgejo-provision-response.json >&2 exit 1 fi fi EOF FORGEJO_PAT="${forgejo_pat}" \ WEBHOOK_SECRET="${webhook_secret}" \ DISPATCHER_SRC="${dispatcher_src}" \ AUTOSCALER_SRC="${autoscaler_src}" \ DISPATCHER_OUT="${dispatcher_out}" \ AUTOSCALER_OUT="${autoscaler_out}" \ python3 - <<'PY' import os from pathlib import Path def render(src: str, dst: str) -> None: text = Path(src).read_text(encoding="utf-8") text = text.replace("PENDING-FORGEJO-PAT", os.environ["FORGEJO_PAT"]) text = text.replace("PENDING-WEBHOOK-SECRET", os.environ["WEBHOOK_SECRET"]) Path(dst).write_text(text, encoding="utf-8") render(os.environ["DISPATCHER_SRC"], os.environ["DISPATCHER_OUT"]) render(os.environ["AUTOSCALER_SRC"], os.environ["AUTOSCALER_OUT"]) PY chmod 600 "${dispatcher_out}" "${autoscaler_out}" burrow_encrypt_secret_from_file "${REPO_ROOT}" "${token_secret}" "${token_file}" burrow_encrypt_secret_from_file "${REPO_ROOT}" "${dispatcher_secret}" "${dispatcher_out}" burrow_encrypt_secret_from_file "${REPO_ROOT}" "${autoscaler_secret}" "${autoscaler_out}" echo "Updated secrets/forgejo/{nsc-token,nsc-dispatcher-config,nsc-autoscaler-config}.age." echo "Minted Forgejo PAT ${token_name} for ${CONTACT_USER} on ${HOST}."