From c87f76a78a37abbc103113abb5019c006bf8c5d1 Mon Sep 17 00:00:00 2001 From: Timothy Kim Date: Fri, 27 Mar 2026 12:28:32 -0400 Subject: [PATCH] rename new-service.sh to service.sh with subcommands --- CLAUDE.md | 2 +- README.md | 2 +- tools/new-service.sh | 73 ------------- tools/service.sh | 243 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 75 deletions(-) delete mode 100755 tools/new-service.sh create mode 100755 tools/service.sh diff --git a/CLAUDE.md b/CLAUDE.md index f573d1d..f1b18db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ for technical deep-dives, USECASES.md for expected behaviors. - `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/app.sh` -- App provisioning by subcommand (dns, repo, files, cert, garage, build, verify, all) -- `tools/new-service.sh` -- Scaffold a new Docker Compose service (compose + workflow) +- `tools/service.sh` -- Manage Docker services by subcommand (dns, files, all) - `tools/remove-app.sh` -- Remove a static site (local files + Gitea repo) - `docker/nginx/conf.d/` -- Per-app nginx server blocks - `docker//compose.yml` -- Per-app compose files diff --git a/README.md b/README.md index 92fd553..7d8a1fd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ scripts/ deploy.sh # Deploy + provision (called via SSH) tools/ app.sh # Add a new static site (run from dev machine) - new-service.sh # Add a new Docker service (run from dev machine) + service.sh # Manage Docker services by subcommand (dns, files, all) remove-app.sh # Remove a static site (run from dev machine) docker/ nginx/ # Reverse proxy (nginx:alpine) + SSL config diff --git a/tools/new-service.sh b/tools/new-service.sh deleted file mode 100755 index fc3e815..0000000 --- a/tools/new-service.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -set -euo pipefail - -if [ -z "${1:-}" ]; then - echo "Usage: ./tools/new-service.sh " - echo " Example: ./tools/new-service.sh garage" - echo "" - echo "Creates docker//compose.yml and .gitea/workflows/deploy-.yml" - exit 1 -fi - -APP="$1" - -if ! [[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then - echo "Error: 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 - -echo "Creating docker/$APP/compose.yml..." -mkdir -p "$REPO_ROOT/docker/$APP" -touch "$REPO_ROOT/docker/$APP/.gitignore" -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 "Done. Edit docker/$APP/compose.yml, then commit and push." -echo "" -echo "If this service needs secrets:" -echo " 1. Add secrets to Bitwarden Secrets Manager" -echo " 2. Map them in docker/$APP/.env.keys (format: ENV_VAR=bws-secret-name)" -echo " 3. Add env_file: .env to compose.yml" -echo " configure.sh will generate the .env file on the server automatically." diff --git a/tools/service.sh b/tools/service.sh new file mode 100755 index 0000000..6af9634 --- /dev/null +++ b/tools/service.sh @@ -0,0 +1,243 @@ +#!/bin/bash +set -euo pipefail + +usage() { + echo "Usage: ./tools/service.sh " + echo "" + echo "Commands:" + echo " dns Create A record for .hantim.net" + echo " files Create compose.yml and deploy workflow" + echo " all Run dns + files" + echo "" + echo "Examples:" + echo " ./tools/service.sh files garage" + echo " ./tools/service.sh dns garage # garage.hantim.net -> server IP" + echo " ./tools/service.sh all garage" + echo "" + echo "Requires: bws, jq, dig (for dns)" + exit 1 +} + +COMMAND="${1:-}" + +if [ -z "$COMMAND" ]; then + usage +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# --- Dependency checks --- + +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" +} + +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 +} + +validate_name() { + local name="$1" + if [ -z "$name" ]; then + echo "Error: service name is required." + echo "" + usage + fi + if ! [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then + echo "Error: name must be alphanumeric (hyphens, dots, underscores allowed)." + exit 1 + fi +} + +# --- Commands --- + +cmd_dns() { + local name="$1" + local zone="hantim.net" + local fqdn="$name.$zone" + + require_dig + require_bws + + SERVER_IP=$(dig +short "$zone" | head -1) + if [ -z "$SERVER_IP" ]; then + echo "Error: Could not resolve $zone to get server IP." + exit 1 + fi + + 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 existing A records for $name in $zone..." + RECORDS=$(curl -s "$VULTR_API/domains/$zone/records" -H "$VULTR_AUTH") + + echo "$RECORDS" | jq -r --arg sub "$name" \ + '.records[] | select(.type == "A" and .name == $sub) | .id' | while read -r id; do + echo " Deleting existing A record for $fqdn (id: $id)" + curl -s -X DELETE "$VULTR_API/domains/$zone/records/$id" -H "$VULTR_AUTH" + done + + echo " Creating A record: $fqdn -> $SERVER_IP" + RESP=$(mktemp) + HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ + -X POST "$VULTR_API/domains/$zone/records" \ + -H "$VULTR_AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$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 (HTTP $HTTP_CODE)" + cat "$RESP" + rm -f "$RESP" + exit 1 + fi + rm -f "$RESP" + + echo "==> Verifying DNS propagation for $fqdn..." + for i in $(seq 1 12); do + RESOLVED=$(dig +short "$fqdn" @ns1.vultr.com 2>/dev/null | head -1) + if [ "$RESOLVED" = "$SERVER_IP" ]; then + echo " DNS is live: $fqdn -> $SERVER_IP" + return + fi + if [ "$i" = "12" ]; then + echo " Warning: $fqdn did not resolve to $SERVER_IP after 60 seconds. It may take longer to propagate." + return + fi + sleep 5 + done +} + +cmd_files() { + local name="$1" + + if [ -d "$REPO_ROOT/docker/$name" ]; then + echo "Error: docker/$name already exists." + exit 1 + fi + + echo "==> Creating docker/$name/compose.yml..." + mkdir -p "$REPO_ROOT/docker/$name" + touch "$REPO_ROOT/docker/$name/.gitignore" + cat > "$REPO_ROOT/docker/$name/compose.yml" < Creating .gitea/workflows/deploy-$name.yml..." + mkdir -p "$REPO_ROOT/.gitea/workflows" + cat > "$REPO_ROOT/.gitea/workflows/deploy-$name.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 "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER +OUTER + sed -i "s/APP_PLACEHOLDER/$name/g" "$REPO_ROOT/.gitea/workflows/deploy-$name.yml" + + echo "" + echo "Done. Edit docker/$name/compose.yml, then commit and push." + echo "" + echo "If this service needs secrets:" + echo " 1. Add secrets to Bitwarden Secrets Manager" + echo " 2. Map them in docker/$name/.env.keys (format: ENV_VAR=bws-secret-name)" + echo " 3. Add env_file: .env to compose.yml" + echo " configure.sh will generate the .env file on the server automatically." +} + +cmd_all() { + local name="$1" + + cmd_dns "$name" + cmd_files "$name" +} + +# --- Dispatch --- + +case "$COMMAND" in + dns) + validate_name "${2:-}" + cmd_dns "${2:-}" + ;; + files) + validate_name "${2:-}" + cmd_files "${2:-}" + ;; + all) + validate_name "${2:-}" + cmd_all "${2:-}" + ;; + *) + echo "Unknown command: $COMMAND" + echo "" + usage + ;; +esac