diff --git a/CLAUDE.md b/CLAUDE.md index 0fdf0e5..5f56469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ for technical deep-dives, USECASES.md for expected behaviors. - `scripts/bootstrap.sh` -- First-time server setup (manual, uses Bitwarden) - `scripts/configure.sh` -- Idempotent server config (firewall, certbot, start services; CI-safe) - `scripts/deploy.sh` -- SSH-triggered deploy + cert issuance + provision (deploy-*, cert-*, provision commands) -- `tools/new-app.sh` -- Single-command new static site (DNS + Gitea repo + cert + bucket + deploy + verify) +- `tools/app.sh` -- App provisioning by subcommand (dns, repo, files, cert, garage, build, verify, all) +- `tools/new-app.sh` -- Wrapper for `app.sh all` (backwards compat) - `tools/new-service.sh` -- Scaffold a new Docker Compose service (compose + workflow) - `tools/remove-app.sh` -- Remove a static site (local files + Gitea repo) - `docker/nginx/conf.d/` -- Per-app nginx server blocks diff --git a/tools/app.sh b/tools/app.sh new file mode 100755 index 0000000..3136f3e --- /dev/null +++ b/tools/app.sh @@ -0,0 +1,506 @@ +#!/bin/bash +set -euo pipefail + +GITEA_URL="https://git.timothykim.net" +GITEA_ORG="hantim" +TEMPLATE_REPO="static-site-template" + +SPINNER='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' +spin_wait() { + local secs=$1 msg=$2 start=$3 i=0 + while [ $i -lt $((secs * 4)) ]; do + local elapsed=$(( start + i / 4 )) + local sc=${SPINNER:$(( (i) % ${#SPINNER} )):1} + printf "\r %s %s (%ds)" "$sc" "$msg" "$elapsed" + sleep 0.25 + i=$((i + 1)) + done +} + +usage() { + echo "Usage: ./tools/app.sh " + echo "" + echo "Commands:" + echo " dns Set up Vultr DNS zone and A records" + echo " repo Create Gitea repo from template" + echo " files Create local compose, workflow, nginx conf" + echo " cert Issue SSL certificate via deploy SSH" + echo " garage Create Garage media bucket and grant access" + echo " build Trigger initial build workflow" + echo " verify Check if site is live" + echo " all Run all steps (dns, repo, files, cert, garage, commit, build, verify)" + echo "" + echo "Requires: bws, jq, dig" + exit 1 +} + +COMMAND="${1:-}" +APP="${2:-}" + +if [ -z "$COMMAND" ] || [ -z "$APP" ]; then + usage +fi + +if ! [[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then + echo "Error: app name must be alphanumeric (hyphens, dots, underscores allowed)." + exit 1 +fi + +CONTAINER_NAME="${APP//./_}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# --- Dependency checks (only what each command needs) --- + +require_dig() { + if ! command -v dig &>/dev/null; then + echo "Error: dig is not installed." + echo " On macOS: brew install bind" + echo " On Linux: dnf install bind-utils" + exit 1 + fi +} + +require_bws() { + if ! command -v bws &>/dev/null; then + echo "Error: Bitwarden Secrets Manager CLI (bws) is not installed." + echo " https://github.com/bitwarden/sdk-sm/releases" + exit 1 + fi + if ! command -v jq &>/dev/null; then + echo "Error: jq is not installed." + exit 1 + fi + + BWS_TOKEN_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hantim/bws-token" + if [ -z "${BWS_ACCESS_TOKEN:-}" ]; then + if [ -f "$BWS_TOKEN_FILE" ]; then + BWS_ACCESS_TOKEN=$(cat "$BWS_TOKEN_FILE") + else + echo "==> Bitwarden Secrets Manager access token required." + echo " Generate one for your machine account at:" + echo " https://vault.bitwarden.com/#/sm/machine-accounts" + read -rp " Paste access token: " BWS_ACCESS_TOKEN + if [ -z "$BWS_ACCESS_TOKEN" ]; then + echo "ERROR: Access token cannot be empty." + exit 1 + fi + mkdir -p "$(dirname "$BWS_TOKEN_FILE")" + echo "$BWS_ACCESS_TOKEN" > "$BWS_TOKEN_FILE" + chmod 600 "$BWS_TOKEN_FILE" + fi + fi + export BWS_ACCESS_TOKEN +} + +bws_get() { + local name="$1" + local value + value=$(bws secret list | jq -r --arg name "$name" '.[] | select(.key == $name) | .value') + if [ -z "$value" ]; then + echo "ERROR: Secret '$name' not found in Bitwarden Secrets Manager." >&2 + exit 1 + fi + echo "$value" +} + +resolve_server_ip() { + require_dig + SERVER_IP=$(dig +short hantim.net | head -1) + if [ -z "$SERVER_IP" ]; then + echo "Error: Could not resolve hantim.net to get server IP." + exit 1 + fi +} + +setup_deploy_key() { + require_bws + echo "==> Fetching deploy key..." + DEPLOY_KEY=$(bws_get "hantim-deploy-ssh-private-key") + DEPLOY_KEY_FILE=$(mktemp) + echo "$DEPLOY_KEY" > "$DEPLOY_KEY_FILE" + chmod 600 "$DEPLOY_KEY_FILE" + trap "rm -f $DEPLOY_KEY_FILE" EXIT +} + +# --- Commands --- + +cmd_dns() { + resolve_server_ip + require_bws + + echo "==> Fetching Vultr API key..." + VULTR_API_KEY=$(bws_get "hantim-vultr-api-key") + VULTR_API="https://api.vultr.com/v2" + VULTR_AUTH="Authorization: Bearer $VULTR_API_KEY" + + echo "==> Checking DNS zone for $APP on Vultr..." + ZONE_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ + "$VULTR_API/domains/$APP" \ + -H "$VULTR_AUTH") + + if [ "$ZONE_CHECK" != "200" ]; then + echo " Creating DNS zone for $APP..." + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$VULTR_API/domains" \ + -H "$VULTR_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"domain\": \"$APP\"}") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then + echo "Error: Failed to create DNS zone (HTTP $HTTP_CODE)" + exit 1 + fi + echo " Created DNS zone for $APP" + fi + + echo "==> Setting A records for $APP..." + RECORDS=$(curl -s "$VULTR_API/domains/$APP/records" -H "$VULTR_AUTH") + + echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "") | .id' | while read -r id; do + echo " Deleting existing A record for $APP (id: $id)" + curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" + done + + echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "www") | .id' | while read -r id; do + echo " Deleting existing A record for www.$APP (id: $id)" + curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" + done + + echo " Creating A record: $APP -> $SERVER_IP" + RESP=$(mktemp) + HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ + -X POST "$VULTR_API/domains/$APP/records" \ + -H "$VULTR_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then + echo "Error: Failed to create A record for $APP (HTTP $HTTP_CODE)" + cat "$RESP" + rm -f "$RESP" + exit 1 + fi + rm -f "$RESP" + + echo " Creating A record: www.$APP -> $SERVER_IP" + RESP=$(mktemp) + HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ + -X POST "$VULTR_API/domains/$APP/records" \ + -H "$VULTR_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"www\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then + echo "Error: Failed to create A record for www.$APP (HTTP $HTTP_CODE)" + cat "$RESP" + rm -f "$RESP" + exit 1 + fi + rm -f "$RESP" + + echo "==> Waiting for DNS to propagate..." + ELAPSED=0 + for i in $(seq 1 30); do + RESOLVED=$(dig +short "$APP" @ns1.vultr.com 2>/dev/null) + if [ "$RESOLVED" = "$SERVER_IP" ]; then + printf "\r\033[K DNS is live.\n" + return + fi + if [ "$i" = "30" ]; then + printf "\r\033[K" + echo "Error: DNS for $APP did not resolve to $SERVER_IP after 5 minutes." + echo " If this is a new domain, make sure nameservers are set to Vultr on directnic.com." + exit 1 + fi + spin_wait 10 "Waiting for DNS..." "$ELAPSED" + ELAPSED=$((ELAPSED + 10)) + done +} + +cmd_repo() { + require_bws + + echo "==> Fetching Gitea token..." + GITEA_TOKEN=$(bws_get "hantim-new-app-script") + + REPO_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ + "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP" \ + -H "Authorization: token $GITEA_TOKEN") + if [ "$REPO_CHECK" = "200" ]; then + echo " Gitea repo $GITEA_ORG/$APP already exists, skipping." + return + fi + + echo "==> Creating Gitea repo $GITEA_ORG/$APP from template $TEMPLATE_REPO..." + HTTP_CODE=$(curl -s -o /tmp/new-app-response.json -w "%{http_code}" \ + -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$TEMPLATE_REPO/generate" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"owner\": \"$GITEA_ORG\", + \"name\": \"$APP\", + \"git_content\": true, + \"labels\": false, + \"webhooks\": false + }") + + if [ "$HTTP_CODE" != "201" ]; then + echo "Error: Failed to create repo (HTTP $HTTP_CODE)" + cat /tmp/new-app-response.json + rm -f /tmp/new-app-response.json + exit 1 + fi + rm -f /tmp/new-app-response.json + echo " Created: $GITEA_URL/$GITEA_ORG/$APP" +} + +cmd_files() { + for f in "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf"; do + if [ -e "$REPO_ROOT/$f" ]; then + echo "Error: $f already exists." + exit 1 + fi + done + + echo "==> Creating docker/$APP/compose.yml..." + mkdir -p "$REPO_ROOT/docker/$APP" + cat > "$REPO_ROOT/docker/$APP/compose.yml" < Creating .gitea/workflows/deploy-$APP.yml..." + mkdir -p "$REPO_ROOT/.gitea/workflows" + cat > "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" <<'OUTER' +name: Deploy APP_PLACEHOLDER + +on: + push: + branches: [main] + paths: + - 'docker/APP_PLACEHOLDER/**' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER +OUTER + sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" + + echo "==> Creating docker/nginx/conf.d/$APP.conf..." + cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" < Issuing SSL certificate for $APP via deploy user..." + ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$APP" + echo " Certificate issued." +} + +cmd_garage() { + require_bws + + echo "==> Fetching Garage secrets..." + GARAGE_ADMIN_TOKEN=$(bws_get "hantim-garage-admin-token") + GARAGE_MEDIA_KEY_ID=$(bws_get "hantim-garage-media-key-id") + + GARAGE_API="https://garage.hantim.net" + GARAGE_AUTH="Authorization: Bearer $GARAGE_ADMIN_TOKEN" + + echo "==> Creating Garage media bucket for $APP..." + BUCKET_RESP=$(curl -s -X POST "$GARAGE_API/v2/CreateBucket" \ + -H "$GARAGE_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"globalAlias\": \"$APP\"}") + + BUCKET_ID=$(echo "$BUCKET_RESP" | jq -r '.id') + if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then + BUCKET_ID=$(curl -s -X POST "$GARAGE_API/v2/GetBucketInfo" \ + -H "$GARAGE_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"globalAlias\": \"$APP\"}" | jq -r '.id') + if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then + echo "Error: Failed to create or find bucket for $APP" + echo "$BUCKET_RESP" + exit 1 + fi + echo " Bucket already exists, continuing..." + fi + + echo " Allowing media-key access..." + curl -sf -X POST "$GARAGE_API/v2/AllowBucketKey" \ + -H "$GARAGE_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"bucketId\": \"$BUCKET_ID\", \"accessKeyId\": \"$GARAGE_MEDIA_KEY_ID\", \"permissions\": {\"read\": true, \"write\": true, \"owner\": false}}" > /dev/null + + echo " Enabling website access..." + curl -sf -X POST "$GARAGE_API/v2/UpdateBucket?id=$BUCKET_ID" \ + -H "$GARAGE_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"websiteAccess": {"enabled": true, "indexDocument": "index.html"}}' > /dev/null + + echo " Bucket '$APP' ready." + echo "" + echo " Upload media:" + echo " aws --endpoint-url https://s3.hantim.net s3 cp s3://$APP/" + echo " aws --endpoint-url https://s3.hantim.net s3 sync static/media/ s3://$APP/" + echo " Served at: https://www.$APP/media/" +} + +cmd_build() { + require_bws + + echo "==> Fetching Gitea token..." + GITEA_TOKEN=$(bws_get "hantim-new-app-script") + + echo "==> Triggering initial build for $APP..." + ELAPSED=0 + TRIGGER_CODE="" + for i in $(seq 1 60); do + TRIGGER_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP/actions/workflows/build.yml/dispatches" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"ref": "main"}') + [ "$TRIGGER_CODE" != "404" ] && printf "\r\033[K" && break + spin_wait 2 "Waiting for repo to be ready..." "$ELAPSED" + ELAPSED=$((ELAPSED + 2)) + done + + if [ "$TRIGGER_CODE" = "204" ]; then + echo " Build triggered." + else + echo " Warning: Could not trigger build (HTTP $TRIGGER_CODE). Check manually:" + echo " $GITEA_URL/$GITEA_ORG/$APP/actions" + fi +} + +cmd_verify() { + echo "==> Checking if https://www.$APP is live..." + ELAPSED=0 + for i in $(seq 1 18); do + if curl -sf "https://www.$APP" > /dev/null 2>&1; then + printf "\r\033[K Site is live at https://www.$APP\n" + return + fi + spin_wait 10 "Waiting for site..." "$ELAPSED" + ELAPSED=$((ELAPSED + 10)) + done + printf "\r\033[K" + + echo "WARNING: https://www.$APP is not responding after 3 minutes." + echo " Check that:" + echo " - DNS for $APP and www.$APP points to the server" + echo " - The build completed: $GITEA_URL/$GITEA_ORG/$APP/actions" + echo " - The container is running on the server: docker ps" +} + +cmd_all() { + cmd_dns + cmd_repo + cmd_files + + echo "==> Committing and pushing hantim-server..." + cd "$REPO_ROOT" + git add "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf" + if git diff --cached --quiet; then + echo " No changes to commit, skipping." + else + git commit -m "add $APP" + git push + fi + + cmd_cert + cmd_garage + cmd_build + cmd_verify +} + +# --- Dispatch --- + +case "$COMMAND" in + dns) cmd_dns ;; + repo) cmd_repo ;; + files) cmd_files ;; + cert) cmd_cert ;; + garage) cmd_garage ;; + build) cmd_build ;; + verify) cmd_verify ;; + all) cmd_all ;; + *) + echo "Unknown command: $COMMAND" + echo "" + usage + ;; +esac diff --git a/tools/new-app.sh b/tools/new-app.sh index 97362b9..271684c 100755 --- a/tools/new-app.sh +++ b/tools/new-app.sh @@ -1,451 +1,13 @@ #!/bin/bash set -euo pipefail -GITEA_URL="https://git.timothykim.net" -GITEA_ORG="hantim" -TEMPLATE_REPO="static-site-template" -SPINNER='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' -spin_wait() { - local secs=$1 msg=$2 start=$3 i=0 - while [ $i -lt $((secs * 4)) ]; do - local elapsed=$(( start + i / 4 )) - local sc=${SPINNER:$(( (i) % ${#SPINNER} )):1} - printf "\r %s %s (%ds)" "$sc" "$msg" "$elapsed" - sleep 0.25 - i=$((i + 1)) - done -} - +# Thin wrapper — delegates to app.sh all if [ -z "${1:-}" ]; then echo "Usage: ./tools/new-app.sh " echo " Example: ./tools/new-app.sh example.com" echo "" - echo "Requires: bws, jq, dig" + echo "For individual steps, use: ./tools/app.sh " exit 1 fi -APP="$1" -CONTAINER_NAME="${APP//./_}" -SERVER_IP=$(dig +short hantim.net | head -1) - -if [ -z "$SERVER_IP" ]; then - echo "Error: Could not resolve hantim.net to get server IP." - exit 1 -fi - -if ! [[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then - echo "Error: app name must be alphanumeric (hyphens, dots, underscores allowed)." - exit 1 -fi - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -if [ -d "$REPO_ROOT/docker/$APP" ]; then - echo "Error: docker/$APP already exists." - exit 1 -fi - -# --- Check dependencies --- - -if ! command -v bws &>/dev/null; then - echo "Error: Bitwarden Secrets Manager CLI (bws) is not installed." - echo " https://github.com/bitwarden/sdk-sm/releases" - exit 1 -fi - -if ! command -v jq &>/dev/null; then - echo "Error: jq is not installed." - exit 1 -fi - -if ! command -v dig &>/dev/null; then - echo "Error: dig is not installed." - echo " On macOS: brew install bind" - echo " On Linux: dnf install bind-utils" - exit 1 -fi - -# --- Get bws access token --- - -BWS_TOKEN_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hantim/bws-token" - -if [ -z "${BWS_ACCESS_TOKEN:-}" ]; then - if [ -f "$BWS_TOKEN_FILE" ]; then - BWS_ACCESS_TOKEN=$(cat "$BWS_TOKEN_FILE") - else - echo "==> Bitwarden Secrets Manager access token required." - echo " Generate one for your machine account at:" - echo " https://vault.bitwarden.com/#/sm/machine-accounts" - read -rp " Paste access token: " BWS_ACCESS_TOKEN - if [ -z "$BWS_ACCESS_TOKEN" ]; then - echo "ERROR: Access token cannot be empty." - exit 1 - fi - mkdir -p "$(dirname "$BWS_TOKEN_FILE")" - echo "$BWS_ACCESS_TOKEN" > "$BWS_TOKEN_FILE" - chmod 600 "$BWS_TOKEN_FILE" - fi -fi -export BWS_ACCESS_TOKEN - -# --- Fetch secrets from Bitwarden Secrets Manager --- - -bws_get() { - local name="$1" - local value - value=$(bws secret list | jq -r --arg name "$name" '.[] | select(.key == $name) | .value') - if [ -z "$value" ]; then - echo "ERROR: Secret '$name' not found in Bitwarden Secrets Manager." >&2 - exit 1 - fi - echo "$value" -} - -echo "==> Fetching secrets from Bitwarden Secrets Manager..." -GITEA_TOKEN=$(bws_get "hantim-new-app-script") -VULTR_API_KEY=$(bws_get "hantim-vultr-api-key") -DEPLOY_KEY=$(bws_get "hantim-deploy-ssh-private-key") -GARAGE_ADMIN_TOKEN=$(bws_get "hantim-garage-admin-token") -GARAGE_MEDIA_KEY_ID=$(bws_get "hantim-garage-media-key-id") - -DEPLOY_KEY_FILE=$(mktemp) -echo "$DEPLOY_KEY" > "$DEPLOY_KEY_FILE" -chmod 600 "$DEPLOY_KEY_FILE" -trap "rm -f $DEPLOY_KEY_FILE" EXIT - -# --- Set up DNS records on Vultr --- - -VULTR_API="https://api.vultr.com/v2" -VULTR_AUTH="Authorization: Bearer $VULTR_API_KEY" - -echo "==> Checking DNS zone for $APP on Vultr..." -ZONE_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ - "$VULTR_API/domains/$APP" \ - -H "$VULTR_AUTH") - -if [ "$ZONE_CHECK" != "200" ]; then - echo " Creating DNS zone for $APP..." - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "$VULTR_API/domains" \ - -H "$VULTR_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"domain\": \"$APP\"}") - if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then - echo "Error: Failed to create DNS zone (HTTP $HTTP_CODE)" - exit 1 - fi - echo " Created DNS zone for $APP" -fi - -echo "==> Setting A records for $APP..." -RECORDS=$(curl -s "$VULTR_API/domains/$APP/records" -H "$VULTR_AUTH") - -# Delete existing root A records -echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "") | .id' | while read -r id; do - echo " Deleting existing A record for $APP (id: $id)" - curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" -done - -# Delete existing www A records -echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "www") | .id' | while read -r id; do - echo " Deleting existing A record for www.$APP (id: $id)" - curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" -done - -# Create root A record -echo " Creating A record: $APP -> $SERVER_IP" -RESP=$(mktemp) -HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ - -X POST "$VULTR_API/domains/$APP/records" \ - -H "$VULTR_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") -if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then - echo "Error: Failed to create A record for $APP (HTTP $HTTP_CODE)" - cat "$RESP" - rm -f "$RESP" - exit 1 -fi -rm -f "$RESP" - -# Create www A record -echo " Creating A record: www.$APP -> $SERVER_IP" -RESP=$(mktemp) -HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ - -X POST "$VULTR_API/domains/$APP/records" \ - -H "$VULTR_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"www\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") -if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then - echo "Error: Failed to create A record for www.$APP (HTTP $HTTP_CODE)" - cat "$RESP" - rm -f "$RESP" - exit 1 -fi -rm -f "$RESP" - -echo "==> Waiting for DNS to propagate..." -ELAPSED=0 -for i in $(seq 1 30); do - RESOLVED=$(dig +short "$APP" @ns1.vultr.com 2>/dev/null) - if [ "$RESOLVED" = "$SERVER_IP" ]; then - printf "\r\033[K DNS is live.\n" - break - fi - if [ "$i" = "30" ]; then - printf "\r\033[K" - echo "Error: DNS for $APP did not resolve to $SERVER_IP after 5 minutes." - echo " If this is a new domain, make sure nameservers are set to Vultr on directnic.com." - exit 1 - fi - spin_wait 10 "Waiting for DNS..." "$ELAPSED" - ELAPSED=$((ELAPSED + 10)) -done - -# --- Create Gitea repo from template --- - -REPO_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ - "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP" \ - -H "Authorization: token $GITEA_TOKEN") -if [ "$REPO_CHECK" = "200" ]; then - echo "Error: Gitea repo $GITEA_ORG/$APP already exists." - echo " $GITEA_URL/$GITEA_ORG/$APP" - exit 1 -fi - -echo "Creating Gitea repo $GITEA_ORG/$APP from template $TEMPLATE_REPO..." -HTTP_CODE=$(curl -s -o /tmp/new-app-response.json -w "%{http_code}" \ - -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$TEMPLATE_REPO/generate" \ - -H "Authorization: token $GITEA_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"owner\": \"$GITEA_ORG\", - \"name\": \"$APP\", - \"git_content\": true, - \"labels\": false, - \"webhooks\": false - }") - -if [ "$HTTP_CODE" != "201" ]; then - echo "Error: Failed to create repo (HTTP $HTTP_CODE)" - cat /tmp/new-app-response.json - rm -f /tmp/new-app-response.json - exit 1 -fi -rm -f /tmp/new-app-response.json -echo " Created: $GITEA_URL/$GITEA_ORG/$APP" - -# --- Create local files in hantim-server --- - -echo "Creating docker/$APP/compose.yml..." -mkdir -p "$REPO_ROOT/docker/$APP" -cat > "$REPO_ROOT/docker/$APP/compose.yml" < "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" <<'OUTER' -name: Deploy APP_PLACEHOLDER - -on: - push: - branches: [main] - paths: - - 'docker/APP_PLACEHOLDER/**' - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Deploy via SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER -OUTER -sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" - -echo "Creating docker/nginx/conf.d/$APP.conf..." -cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" < Issuing SSL certificate via deploy user..." -ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$APP" - -# --- Create Garage media bucket --- - -GARAGE_API="https://garage.hantim.net" -GARAGE_AUTH="Authorization: Bearer $GARAGE_ADMIN_TOKEN" - -echo "==> Creating Garage media bucket for $APP..." -BUCKET_RESP=$(curl -s -X POST "$GARAGE_API/v2/CreateBucket" \ - -H "$GARAGE_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"globalAlias\": \"$APP\"}") - -BUCKET_ID=$(echo "$BUCKET_RESP" | jq -r '.id') -if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then - # Bucket may already exist — try to look it up - BUCKET_ID=$(curl -s -X POST "$GARAGE_API/v2/GetBucketInfo" \ - -H "$GARAGE_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"globalAlias\": \"$APP\"}" | jq -r '.id') - if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then - echo "Error: Failed to create or find bucket for $APP" - echo "$BUCKET_RESP" - exit 1 - fi - echo " Bucket already exists, continuing..." -fi - -echo " Allowing media-key access..." -curl -sf -X POST "$GARAGE_API/v2/AllowBucketKey" \ - -H "$GARAGE_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"bucketId\": \"$BUCKET_ID\", \"accessKeyId\": \"$GARAGE_MEDIA_KEY_ID\", \"permissions\": {\"read\": true, \"write\": true, \"owner\": false}}" > /dev/null - -echo " Enabling website access..." -curl -sf -X POST "$GARAGE_API/v2/UpdateBucket?id=$BUCKET_ID" \ - -H "$GARAGE_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"websiteAccess": {"enabled": true, "indexDocument": "index.html"}}' > /dev/null - -echo " Bucket '$APP' ready" - -# --- Commit and push --- - -echo "==> Committing and pushing hantim-server..." -cd "$REPO_ROOT" -git add "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf" -git commit -m "add $APP" -git push - -# --- Trigger initial build --- - -echo "==> Triggering initial build for $APP..." -# Wait for template repo to be ready, then dispatch the build workflow -ELAPSED=0 -for i in $(seq 1 60); do - TRIGGER_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP/actions/workflows/build.yml/dispatches" \ - -H "Authorization: token $GITEA_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"ref": "main"}') - [ "$TRIGGER_CODE" != "404" ] && printf "\r\033[K" && break - spin_wait 2 "Waiting for repo to be ready..." "$ELAPSED" - ELAPSED=$((ELAPSED + 2)) -done - -if [ "$TRIGGER_CODE" = "204" ]; then - echo " Build triggered." -else - echo " Warning: Could not trigger build (HTTP $TRIGGER_CODE). Check manually:" - echo " $GITEA_URL/$GITEA_ORG/$APP/actions" -fi - -# --- Verify --- - -echo "==> Waiting for deployment (up to 3 minutes)..." -ELAPSED=0 -for i in $(seq 1 18); do - if curl -sf "https://www.$APP" > /dev/null 2>&1; then - printf "\r\033[K Site is live at https://www.$APP\n" - echo "" - echo "==> Next steps: media files" - echo " Create a local media directory for testing:" - echo " mkdir -p $APP/static/media" - echo "" - echo " Upload media to Garage:" - echo " aws --endpoint-url https://s3.hantim.net s3 cp s3://$APP/" - echo " aws --endpoint-url https://s3.hantim.net s3 sync static/media/ s3://$APP/" - echo "" - echo " Media files are served at https://www.$APP/media/" - exit 0 - fi - spin_wait 10 "Waiting for site..." "$ELAPSED" - ELAPSED=$((ELAPSED + 10)) -done -printf "\r\033[K" - -echo "WARNING: https://www.$APP is not responding after 3 minutes." -echo " Check that:" -echo " - DNS for $APP and www.$APP points to $SERVER_IP" -echo " - The build completed: $GITEA_URL/$GITEA_ORG/$APP/actions" -echo " - The container is running on the server: docker ps" -echo "" -echo "==> Next steps: media files" -echo " Create a local media directory for testing:" -echo " mkdir -p $APP/static/media" -echo "" -echo " Upload media to Garage:" -echo " aws --endpoint-url https://s3.hantim.net s3 cp s3://$APP/" -echo " aws --endpoint-url https://s3.hantim.net s3 sync static/media/ s3://$APP/" -echo "" -echo " Media files are served at https://www.$APP/media/" +exec "$(dirname "$0")/app.sh" all "$1"