Files
hantim-server/scripts/new-app.sh
T

334 lines
10 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
GITEA_URL="https://git.timothykim.net"
GITEA_ORG="hantim"
TEMPLATE_REPO="static-site-template"
if [ -z "${1:-}" ]; then
echo "Usage: ./scripts/new-app.sh <domain>"
echo " Example: ./scripts/new-app.sh hcsuzuki.net"
echo ""
echo "Requires: bw, jq, dig"
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 bw &>/dev/null; then
echo "Error: Bitwarden CLI (bw) is not installed."
echo " npm install -g @bitwarden/cli"
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 secrets (cached in env vars, or fetch from Bitwarden) ---
if [ -n "${HANTIM_GITEA_TOKEN:-}" ] && [ -n "${HANTIM_VULTR_API_KEY:-}" ] && [ -n "${HANTIM_DEPLOY_KEY:-}" ]; then
echo "Using cached secrets from environment."
GITEA_TOKEN="$HANTIM_GITEA_TOKEN"
VULTR_API_KEY="$HANTIM_VULTR_API_KEY"
DEPLOY_KEY="$HANTIM_DEPLOY_KEY"
else
if bw status 2>/dev/null | grep -q '"status":"unauthenticated"'; then
echo "Log in to Bitwarden:"
bw login
fi
if bw status 2>/dev/null | grep -q '"status":"locked"'; then
BW_SESSION=$(bw unlock --raw)
export BW_SESSION
fi
bw sync --session "${BW_SESSION:-}"
GITEA_TOKEN=$(bw get notes hantim-new-app-script --session "${BW_SESSION:-}")
VULTR_API_KEY=$(bw get notes hantim-vultr-api-key --session "${BW_SESSION:-}")
DEPLOY_KEY=$(bw get item hantim-server-deploy --session "${BW_SESSION:-}" | jq -r '.sshKey.privateKey')
export HANTIM_GITEA_TOKEN="$GITEA_TOKEN"
export HANTIM_VULTR_API_KEY="$VULTR_API_KEY"
export HANTIM_DEPLOY_KEY="$DEPLOY_KEY"
fi
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 "==> Checking A records for $APP..."
RECORDS=$(curl -s "$VULTR_API/domains/$APP/records" -H "$VULTR_AUTH")
# Check/create root A record
ROOT_EXISTS=$(echo "$RECORDS" | jq -r ".records[] | select(.type == \"A\" and .name == \"\" and .data == \"$SERVER_IP\") | .id")
if [ -z "$ROOT_EXISTS" ]; then
echo " Creating A record: $APP -> $SERVER_IP"
curl -sf -X POST "$VULTR_API/domains/$APP/records" \
-H "$VULTR_AUTH" \
-H "Content-Type: application/json" \
-d "{\"name\": \"\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}" > /dev/null
else
echo " A record for $APP already exists."
fi
# Check/create www A record
WWW_EXISTS=$(echo "$RECORDS" | jq -r ".records[] | select(.type == \"A\" and .name == \"www\" and .data == \"$SERVER_IP\") | .id")
if [ -z "$WWW_EXISTS" ]; then
echo " Creating A record: www.$APP -> $SERVER_IP"
curl -sf -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}" > /dev/null
else
echo " A record for www.$APP already exists."
fi
echo "==> Waiting for DNS to propagate..."
for i in $(seq 1 30); do
RESOLVED=$(dig +short "$APP" @ns1.vultr.com 2>/dev/null)
if [ "$RESOLVED" = "$SERVER_IP" ]; then
echo " DNS is live."
break
fi
if [ "$i" = "30" ]; then
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
sleep 10
done
# --- Create Gitea repo from template ---
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" <<EOF
services:
app:
image: git.timothykim.net/hantim/$APP:latest
restart: unless-stopped
container_name: $CONTAINER_NAME
networks:
- shared
networks:
shared:
external: true
EOF
echo "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" <<NGINX
# Redirect HTTP to HTTPS, bare domain to www
server {
listen 80;
listen [::]:80;
server_name $APP www.$APP;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://www.$APP\$request_uri;
}
}
# Redirect bare HTTPS domain to www
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name $APP;
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
return 301 https://www.$APP\$request_uri;
}
# Main site
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.$APP;
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
location / {
proxy_pass http://$CONTAINER_NAME:80;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
# --- Issue SSL cert (zero downtime) ---
echo "==> Issuing SSL certificate via deploy user..."
ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$APP"
# --- 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 (Gitea generates content asynchronously)
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/contents/.deploy-trigger" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$(echo -n "initial deploy" | base64)\", \"message\": \"trigger initial build\"}")
[ "$TRIGGER_CODE" != "404" ] && break
echo " Waiting for repo to be ready..."
sleep 2
done
if [ "$TRIGGER_CODE" = "201" ]; then
echo " Build triggered."
elif [ "$TRIGGER_CODE" = "422" ]; then
# File already exists — update it to trigger a new build
EXISTING_SHA=$(curl -s "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP/contents/.deploy-trigger" \
-H "Authorization: token $GITEA_TOKEN" | jq -r '.sha')
curl -s -o /dev/null -X PUT "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP/contents/.deploy-trigger" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$(echo -n "redeploy $(date +%s)" | base64)\", \"sha\": \"$EXISTING_SHA\", \"message\": \"trigger rebuild\"}"
echo " Build re-triggered (file already existed)."
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)..."
for i in $(seq 1 18); do
if curl -sf "https://www.$APP" > /dev/null 2>&1; then
echo "Site is live at https://www.$APP"
exit 0
fi
sleep 10
done
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"