feat(gitea): add bootstrap-vm.sh for fresh cloud VM setup
Idempotent end-to-end Gitea bootstrap for Azure VM (or any Linux host
with Docker available). Replaces manual SSH-and-paste workflow.
Steps (each skippable on re-run):
1. Install Docker via official script (skip with --skip-docker)
2. Write /etc/gitea/docker-compose.yml with package registry enabled
3. Start gitea container, wait for HTTP :3300
4. Create admin user via 'gitea admin user create' (CLI inside container,
no auth bootstrap needed)
5. Create npm-user (learning_ai_user) via admin API
6. Mint npm-scoped token with write:package + read:package
Two execution modes:
- On the VM directly: scp + ssh + run
- Locally targeting remote: --ssh-host azureuser@vm
Outputs npm token to --output FILE or stdout. Prints copy-paste-ready
command for writing to ~/.gitea_npm_token_home on the workstation.
Final summary prints the doctor.sh verification command so user can
confirm registry reachability from their laptop in one step.
--dry-run shows planned actions without execution.
--force re-creates users (use after manual deletion).
Closes the 'cloud VM bootstrap' gap identified during the Gitea hardening
review — pairs with scripts/gitea/{doctor,token}.sh from commit 610a59fd.
This commit is contained in:
parent
610a59fdc3
commit
678d8df42c
373
scripts/gitea/bootstrap-vm.sh
Executable file
373
scripts/gitea/bootstrap-vm.sh
Executable file
@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap-vm.sh — Idempotent Gitea bootstrap for a fresh (or existing) cloud VM.
|
||||
#
|
||||
# Goes from "VM running with Docker installed" → "Gitea running on :3300
|
||||
# with admin + learning_ai_user accounts + npm-scoped token written to file".
|
||||
#
|
||||
# This script can run in two modes:
|
||||
#
|
||||
# 1. ON THE VM directly (recommended): SCP this file to the VM, SSH in, run.
|
||||
# scp scripts/gitea/bootstrap-vm.sh azureuser@<vm>:~/
|
||||
# ssh azureuser@<vm> 'bash bootstrap-vm.sh'
|
||||
#
|
||||
# 2. LOCALLY targeting a remote VM via SSH:
|
||||
# bash scripts/gitea/bootstrap-vm.sh --ssh-host azureuser@<vm>
|
||||
#
|
||||
# Usage:
|
||||
# bash bootstrap-vm.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --admin-user NAME Gitea admin username (default: gitea-admin)
|
||||
# --admin-email EMAIL Gitea admin email (default: admin@bytelyst.local)
|
||||
# --admin-pass PASS Gitea admin password (default: generated, printed)
|
||||
# --npm-user NAME Owner for @bytelyst packages (default: learning_ai_user)
|
||||
# --npm-email EMAIL Email for npm-user (default: npm@bytelyst.local)
|
||||
# --gitea-version VER Gitea Docker tag (default: 1.22)
|
||||
# --data-dir PATH Host path for Gitea data (default: /var/lib/gitea)
|
||||
# --output FILE Write npm token to FILE (default: print to stdout
|
||||
# with a fenced block for easy copy)
|
||||
# --ssh-host HOST Run remotely via SSH (e.g. azureuser@10.0.0.4)
|
||||
# --skip-docker Skip Docker install (assume present)
|
||||
# --dry-run Print actions without executing
|
||||
# --force Re-create admin/npm user even if they exist
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 bootstrap succeeded
|
||||
# 1 bootstrap failed at some step (error printed)
|
||||
# 2 bad arguments
|
||||
#
|
||||
# Side effects (idempotent unless --force):
|
||||
# - Installs Docker (skipped if present, or with --skip-docker)
|
||||
# - Creates /var/lib/gitea (or --data-dir) — persistent across restarts
|
||||
# - Writes /etc/gitea/docker-compose.yml
|
||||
# - Starts/restarts `gitea` container on port 3300
|
||||
# - Creates admin user (skipped if exists)
|
||||
# - Creates npm-user via admin API (skipped if exists)
|
||||
# - Mints a new npm-scoped token (always — old token NOT revoked,
|
||||
# just supplemented; rotate via scripts/gitea/token.sh rotate)
|
||||
# - Writes token to --output FILE or stdout
|
||||
#
|
||||
# Re-runnable safely. Re-running:
|
||||
# - Detects existing Gitea, skips install
|
||||
# - Detects existing users, skips creation (unless --force)
|
||||
# - Always mints a fresh token (each run = new token; old ones still valid
|
||||
# unless you revoke them in Gitea UI)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────
|
||||
ADMIN_USER="gitea-admin"
|
||||
ADMIN_EMAIL="admin@bytelyst.local"
|
||||
ADMIN_PASS=""
|
||||
NPM_USER="learning_ai_user"
|
||||
NPM_EMAIL="npm@bytelyst.local"
|
||||
GITEA_VERSION="1.22"
|
||||
DATA_DIR="/var/lib/gitea"
|
||||
OUTPUT_FILE=""
|
||||
SSH_HOST=""
|
||||
SKIP_DOCKER=false
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
GITEA_PORT="3300"
|
||||
GITEA_SSH_PORT="222"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-email) ADMIN_EMAIL="$2"; shift 2 ;;
|
||||
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--npm-user) NPM_USER="$2"; shift 2 ;;
|
||||
--npm-email) NPM_EMAIL="$2"; shift 2 ;;
|
||||
--gitea-version) GITEA_VERSION="$2"; shift 2 ;;
|
||||
--data-dir) DATA_DIR="$2"; shift 2 ;;
|
||||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||||
--ssh-host) SSH_HOST="$2"; shift 2 ;;
|
||||
--skip-docker) SKIP_DOCKER=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
-h|--help) sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Remote re-exec via SSH ──────────────────────────────────────
|
||||
if [ -n "$SSH_HOST" ]; then
|
||||
echo "→ Re-executing on $SSH_HOST via SSH"
|
||||
# Copy this script to the remote and re-run with same args minus --ssh-host
|
||||
ARGS=()
|
||||
[ "$ADMIN_USER" != "gitea-admin" ] && ARGS+=(--admin-user "$ADMIN_USER")
|
||||
[ "$ADMIN_EMAIL" != "admin@bytelyst.local" ] && ARGS+=(--admin-email "$ADMIN_EMAIL")
|
||||
[ -n "$ADMIN_PASS" ] && ARGS+=(--admin-pass "$ADMIN_PASS")
|
||||
[ "$NPM_USER" != "learning_ai_user" ] && ARGS+=(--npm-user "$NPM_USER")
|
||||
[ "$NPM_EMAIL" != "npm@bytelyst.local" ] && ARGS+=(--npm-email "$NPM_EMAIL")
|
||||
[ "$GITEA_VERSION" != "1.22" ] && ARGS+=(--gitea-version "$GITEA_VERSION")
|
||||
[ "$DATA_DIR" != "/var/lib/gitea" ] && ARGS+=(--data-dir "$DATA_DIR")
|
||||
$SKIP_DOCKER && ARGS+=(--skip-docker)
|
||||
$DRY_RUN && ARGS+=(--dry-run)
|
||||
$FORCE && ARGS+=(--force)
|
||||
scp "$0" "$SSH_HOST:/tmp/bootstrap-vm.sh" >/dev/null
|
||||
ssh "$SSH_HOST" "bash /tmp/bootstrap-vm.sh ${ARGS[*]}"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
say() { echo "→ $1"; }
|
||||
ok() { echo " ✓ $1"; }
|
||||
err() { echo " ✗ $1" >&2; exit 1; }
|
||||
run() {
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate random password if not set
|
||||
gen_password() {
|
||||
# 24 chars, alphanumeric (safe for shell + URLs)
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 24
|
||||
else
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$ADMIN_PASS" ]; then
|
||||
ADMIN_PASS="$(gen_password)"
|
||||
PASS_GENERATED=true
|
||||
else
|
||||
PASS_GENERATED=false
|
||||
fi
|
||||
|
||||
# Detect sudo
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
# ── Step 1: Install Docker ──────────────────────────────────────
|
||||
step_docker() {
|
||||
say "Step 1/6: Docker"
|
||||
if $SKIP_DOCKER; then
|
||||
ok "Skipped (--skip-docker)"
|
||||
return
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
ok "Docker + compose plugin already installed ($(docker --version))"
|
||||
return
|
||||
fi
|
||||
say " Installing Docker via official convenience script…"
|
||||
run "curl -fsSL https://get.docker.com | $SUDO sh"
|
||||
run "$SUDO usermod -aG docker \"\$USER\" || true"
|
||||
ok "Docker installed (you may need to re-login for group membership)"
|
||||
}
|
||||
|
||||
# ── Step 2: Write docker-compose.yml ────────────────────────────
|
||||
step_compose() {
|
||||
say "Step 2/6: Gitea docker-compose"
|
||||
run "$SUDO mkdir -p $DATA_DIR /etc/gitea"
|
||||
local compose="/etc/gitea/docker-compose.yml"
|
||||
run "$SUDO tee $compose >/dev/null <<EOF
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:$GITEA_VERSION
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__server__DOMAIN=\$(hostname)
|
||||
- GITEA__server__ROOT_URL=http://\$(hostname):$GITEA_PORT/
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
- GITEA__server__SSH_PORT=$GITEA_SSH_PORT
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
- GITEA__packages__ENABLED=true
|
||||
ports:
|
||||
- \"$GITEA_PORT:3000\"
|
||||
- \"$GITEA_SSH_PORT:22\"
|
||||
volumes:
|
||||
- $DATA_DIR:/data
|
||||
EOF"
|
||||
ok "Wrote $compose"
|
||||
}
|
||||
|
||||
# ── Step 3: Start Gitea + wait for ready ────────────────────────
|
||||
step_start() {
|
||||
say "Step 3/6: Start Gitea container"
|
||||
run "cd /etc/gitea && $SUDO docker compose up -d"
|
||||
if $DRY_RUN; then
|
||||
ok "Would start gitea container"
|
||||
return
|
||||
fi
|
||||
say " Waiting for Gitea HTTP on :$GITEA_PORT (up to 60s)…"
|
||||
local i=0
|
||||
while [ $i -lt 30 ]; do
|
||||
if curl -fsS "http://localhost:$GITEA_PORT/api/v1/version" >/dev/null 2>&1; then
|
||||
ok "Gitea reachable ($(curl -fsS "http://localhost:$GITEA_PORT/api/v1/version"))"
|
||||
return
|
||||
fi
|
||||
sleep 2
|
||||
i=$((i+1))
|
||||
done
|
||||
err "Gitea did not come up within 60s. Check: $SUDO docker logs gitea"
|
||||
}
|
||||
|
||||
# ── Step 4: Create admin user via Gitea CLI ─────────────────────
|
||||
step_admin() {
|
||||
say "Step 4/6: Admin user '$ADMIN_USER'"
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] $SUDO docker exec gitea gitea admin user create --admin ..."
|
||||
return
|
||||
fi
|
||||
# Check if admin already exists
|
||||
if $SUDO docker exec gitea gitea admin user list 2>/dev/null | awk 'NR>1 {print $2}' | grep -qx "$ADMIN_USER"; then
|
||||
if $FORCE; then
|
||||
say " --force: re-setting admin password"
|
||||
$SUDO docker exec gitea gitea admin user change-password \
|
||||
--username "$ADMIN_USER" --password "$ADMIN_PASS" >/dev/null
|
||||
ok "Admin password reset"
|
||||
else
|
||||
ok "Admin '$ADMIN_USER' already exists — leaving password unchanged"
|
||||
PASS_GENERATED=false # don't claim a fresh password if we didn't set one
|
||||
fi
|
||||
else
|
||||
$SUDO docker exec gitea gitea admin user create \
|
||||
--username "$ADMIN_USER" \
|
||||
--password "$ADMIN_PASS" \
|
||||
--email "$ADMIN_EMAIL" \
|
||||
--admin \
|
||||
--must-change-password=false >/dev/null
|
||||
ok "Admin '$ADMIN_USER' created"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Step 5: Create npm-user via API ─────────────────────────────
|
||||
step_npm_user() {
|
||||
say "Step 5/6: NPM owner '$NPM_USER'"
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] curl -u $ADMIN_USER:**** POST /api/v1/admin/users"
|
||||
return
|
||||
fi
|
||||
local base="http://localhost:$GITEA_PORT/api/v1"
|
||||
local code
|
||||
code=$(curl -fsS -o /tmp/gitea-user-check.json -w "%{http_code}" \
|
||||
-u "$ADMIN_USER:$ADMIN_PASS" "$base/users/$NPM_USER" 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] && ! $FORCE; then
|
||||
ok "NPM user '$NPM_USER' already exists"
|
||||
else
|
||||
local npm_pass; npm_pass="$(gen_password)"
|
||||
local resp
|
||||
resp=$(curl -fsS -u "$ADMIN_USER:$ADMIN_PASS" \
|
||||
-X POST "$base/admin/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$NPM_USER\",\"email\":\"$NPM_EMAIL\",\"password\":\"$npm_pass\",\"must_change_password\":false}" 2>&1)
|
||||
if echo "$resp" | grep -q '"id"'; then
|
||||
ok "NPM user '$NPM_USER' created"
|
||||
else
|
||||
err "Failed to create NPM user: $resp"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Step 6: Mint npm token ──────────────────────────────────────
|
||||
step_token() {
|
||||
say "Step 6/6: Mint npm token for '$NPM_USER'"
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] curl -u $NPM_USER POST /api/v1/users/$NPM_USER/tokens"
|
||||
NPM_TOKEN="DRY_RUN_TOKEN_PLACEHOLDER"
|
||||
return
|
||||
fi
|
||||
# We need the npm-user's password to call /tokens — but we only have it
|
||||
# at create time. Workaround: use admin Basic Auth to call the admin-only
|
||||
# `/admin/users/<u>/tokens` endpoint? Gitea does not expose this.
|
||||
#
|
||||
# Approach: temporarily reset npm-user's password via admin, mint via
|
||||
# the npm-user's own creds, then leave the password as-is (it's only used
|
||||
# for token minting; the npm flow uses the token).
|
||||
local npm_pass; npm_pass="$(gen_password)"
|
||||
$SUDO docker exec gitea gitea admin user change-password \
|
||||
--username "$NPM_USER" --password "$npm_pass" >/dev/null 2>&1 || true
|
||||
|
||||
local token_name="npm-bootstrap-$(date +%Y%m%d-%H%M%S)"
|
||||
local base="http://localhost:$GITEA_PORT/api/v1"
|
||||
local resp
|
||||
resp=$(curl -fsS -u "$NPM_USER:$npm_pass" \
|
||||
-X POST "$base/users/$NPM_USER/tokens" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"$token_name\",\"scopes\":[\"write:package\",\"read:package\"]}" 2>&1)
|
||||
NPM_TOKEN=$(echo "$resp" | grep -oE '"(sha1|token)":"[^"]+' | head -1 | sed 's/.*":"//')
|
||||
if [ -z "$NPM_TOKEN" ]; then
|
||||
err "Failed to mint token: $resp"
|
||||
fi
|
||||
ok "Token minted (name=$token_name, ${#NPM_TOKEN} chars)"
|
||||
}
|
||||
|
||||
# ── Output token ────────────────────────────────────────────────
|
||||
output_token() {
|
||||
if [ -n "$OUTPUT_FILE" ]; then
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] would write token to $OUTPUT_FILE"
|
||||
else
|
||||
printf '%s' "$NPM_TOKEN" > "$OUTPUT_FILE"
|
||||
chmod 600 "$OUTPUT_FILE"
|
||||
ok "Token written to $OUTPUT_FILE (mode 600)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Final summary ───────────────────────────────────────────────
|
||||
summary() {
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " ✅ Gitea bootstrap complete"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Gitea URL: http://$(hostname):$GITEA_PORT"
|
||||
echo " Admin user: $ADMIN_USER"
|
||||
if $PASS_GENERATED && [ -z "$OUTPUT_FILE" ]; then
|
||||
echo " Admin password: $ADMIN_PASS ← SAVE THIS NOW (won't be shown again)"
|
||||
fi
|
||||
echo " NPM owner: $NPM_USER"
|
||||
echo " Registry URL: http://$(hostname):$GITEA_PORT/api/packages/$NPM_USER/npm/"
|
||||
echo ""
|
||||
if [ -z "$OUTPUT_FILE" ]; then
|
||||
echo " ── NPM TOKEN (copy this NOW) ──"
|
||||
echo ""
|
||||
echo " $NPM_TOKEN"
|
||||
echo ""
|
||||
echo " Save to ~/.gitea_npm_token_home on your workstation:"
|
||||
echo ""
|
||||
echo " echo -n '$NPM_TOKEN' > ~/.gitea_npm_token_home"
|
||||
echo " chmod 600 ~/.gitea_npm_token_home"
|
||||
echo ""
|
||||
else
|
||||
echo " NPM token: written to $OUTPUT_FILE"
|
||||
echo ""
|
||||
fi
|
||||
echo " Verify (from workstation):"
|
||||
echo ""
|
||||
echo " GITEA_NPM_HOST=$(hostname) GITEA_NPM_OWNER=$NPM_USER \\"
|
||||
echo " GITEA_NPM_TOKEN=\$(cat ~/.gitea_npm_token_home) \\"
|
||||
echo " bash scripts/gitea/doctor.sh"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
say "Gitea VM bootstrap"
|
||||
say " Host: $(hostname)"
|
||||
say " Admin user: $ADMIN_USER"
|
||||
say " NPM owner: $NPM_USER"
|
||||
say " Gitea version: $GITEA_VERSION"
|
||||
say " Data dir: $DATA_DIR"
|
||||
say " Dry-run: $DRY_RUN"
|
||||
echo ""
|
||||
|
||||
step_docker
|
||||
step_compose
|
||||
step_start
|
||||
step_admin
|
||||
step_npm_user
|
||||
step_token
|
||||
output_token
|
||||
summary
|
||||
Loading…
Reference in New Issue
Block a user