294 lines
9.3 KiB
Bash
Executable file
294 lines
9.3 KiB
Bash
Executable file
#!/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 <user@host> SSH target used to mint the Forgejo PAT.
|
|
Default: root@git.burrow.net
|
|
--ssh-key <path> SSH private key for the forge host.
|
|
Default: secrets/forgejo/agent-ssh-key.age, then intake/
|
|
--nsc-bin <path> Override the nsc binary.
|
|
--no-refresh-token Reuse the existing encrypted Namespace token if it already exists.
|
|
--token-name <name> Forgejo PAT name prefix (default: forgejo-nsc)
|
|
--contact-user <name> Forgejo username used for PAT creation (default: contact)
|
|
--scope-owner <name> Forgejo org/user owner for the default NSC scope (default: hackclub)
|
|
--scope-name <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
|
|
"${NSC_BIN}" auth check-login --duration 20m >/dev/null
|
|
raw_token_file="$(mktemp)"
|
|
trap 'rm -f "${raw_token_file}"; cleanup' EXIT
|
|
"${NSC_BIN}" auth generate-dev-token --output_to "${raw_token_file}" >/dev/null
|
|
RAW_NSC_TOKEN_FILE="${raw_token_file}" TOKEN_FILE="${token_file}" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
raw = Path(os.environ["RAW_NSC_TOKEN_FILE"]).read_text(encoding="utf-8").strip()
|
|
if not raw:
|
|
raise SystemExit("generated Namespace token is empty")
|
|
|
|
Path(os.environ["TOKEN_FILE"]).write_text(
|
|
json.dumps({"bearer_token": raw}, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
PY
|
|
rm -f "${raw_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) and isinstance(parsed.get("bearer_token"), str) and parsed["bearer_token"].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' <<EOF
|
|
set -euo pipefail
|
|
|
|
base_url='http://127.0.0.1:3000'
|
|
token='${forgejo_pat}'
|
|
scope_owner='${SCOPE_OWNER}'
|
|
scope_name='${SCOPE_NAME}'
|
|
|
|
api() {
|
|
curl -sS -o /tmp/forgejo-provision-response.json -w '%{http_code}' \
|
|
-H "Authorization: token \${token}" \
|
|
-H 'Content-Type: application/json' \
|
|
"\$@"
|
|
}
|
|
|
|
org_code="\$(api "\${base_url}/api/v1/orgs/\${scope_owner}")"
|
|
if [[ "\${org_code}" == "404" ]]; then
|
|
cat >/tmp/forgejo-provision-org.json <<JSON
|
|
{"username":"${SCOPE_OWNER}","full_name":"${SCOPE_OWNER}","visibility":"public"}
|
|
JSON
|
|
org_code="\$(api -X POST --data @/tmp/forgejo-provision-org.json "\${base_url}/api/v1/orgs")"
|
|
if [[ "\${org_code}" != "201" ]]; then
|
|
echo "failed to create Forgejo org ${SCOPE_OWNER} (HTTP \${org_code})" >&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 <<JSON
|
|
{"name":"${SCOPE_NAME}","description":"Burrow forge bootstrap repository","private":false,"default_branch":"main","auto_init":false}
|
|
JSON
|
|
repo_code="\$(api -X POST --data @/tmp/forgejo-provision-repo.json "\${base_url}/api/v1/orgs/\${scope_owner}/repos")"
|
|
if [[ "\${repo_code}" != "201" ]]; then
|
|
echo "failed to create Forgejo repo ${SCOPE_OWNER}/${SCOPE_NAME} (HTTP \${repo_code})" >&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}."
|