|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +# Towlion CLI — wraps common platform operations |
| 5 | +VERSION="1.0.0" |
| 6 | + |
| 7 | +# Colors |
| 8 | +RED='\033[0;31m' |
| 9 | +GREEN='\033[0;32m' |
| 10 | +YELLOW='\033[1;33m' |
| 11 | +CYAN='\033[0;36m' |
| 12 | +BOLD='\033[1m' |
| 13 | +NC='\033[0m' |
| 14 | + |
| 15 | +info() { echo -e "${GREEN}[INFO]${NC} $*"; } |
| 16 | +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } |
| 17 | +error() { echo -e "${RED}[ERROR]${NC} $*"; } |
| 18 | + |
| 19 | +# Load config |
| 20 | +CONFIG_FILE="${HOME}/.towlion.conf" |
| 21 | + |
| 22 | +load_config() { |
| 23 | + if [ -f "$CONFIG_FILE" ]; then |
| 24 | + # shellcheck source=/dev/null |
| 25 | + source "$CONFIG_FILE" |
| 26 | + fi |
| 27 | + |
| 28 | + SERVER_HOST="${SERVER_HOST:-}" |
| 29 | + SERVER_USER="${SERVER_USER:-deploy}" |
| 30 | + SSH_KEY_PATH="${SSH_KEY_PATH:-${HOME}/.ssh/id_rsa}" |
| 31 | +} |
| 32 | + |
| 33 | +require_config() { |
| 34 | + if [ -z "$SERVER_HOST" ]; then |
| 35 | + error "SERVER_HOST not configured." |
| 36 | + echo "Create ${CONFIG_FILE} with:" |
| 37 | + echo " SERVER_HOST=your-server-ip" |
| 38 | + echo " SERVER_USER=deploy" |
| 39 | + echo " SSH_KEY_PATH=~/.ssh/id_rsa" |
| 40 | + exit 1 |
| 41 | + fi |
| 42 | +} |
| 43 | + |
| 44 | +ssh_cmd() { |
| 45 | + ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new "${SERVER_USER}@${SERVER_HOST}" "$@" |
| 46 | +} |
| 47 | + |
| 48 | +# --- Commands --- |
| 49 | + |
| 50 | +cmd_status() { |
| 51 | + require_config |
| 52 | + echo -e "${BOLD}App Status${NC}" |
| 53 | + echo "" |
| 54 | + ssh_cmd 'for dir in /opt/apps/*/; do |
| 55 | + app=$(basename "$dir") |
| 56 | + [[ "$app" == *-pr-* ]] && continue |
| 57 | + slot_file="${dir}.deploy-slot" |
| 58 | + if [ -f "$slot_file" ]; then |
| 59 | + slot=$(cat "$slot_file") |
| 60 | + container="${app}-${slot}-app-1" |
| 61 | + else |
| 62 | + container="${app}-app-1" |
| 63 | + fi |
| 64 | + status=$(docker inspect --format="{{.State.Status}}" "$container" 2>/dev/null || echo "not found") |
| 65 | + health=$(docker inspect --format="{{.State.Health.Status}}" "$container" 2>/dev/null || echo "n/a") |
| 66 | + printf " %-20s status=%-10s health=%-10s\n" "$app" "$status" "$health" |
| 67 | + done' |
| 68 | +} |
| 69 | + |
| 70 | +cmd_logs() { |
| 71 | + require_config |
| 72 | + local app="${1:-}" |
| 73 | + if [ -z "$app" ]; then |
| 74 | + error "Usage: towlion logs <app-name>" |
| 75 | + exit 1 |
| 76 | + fi |
| 77 | + ssh_cmd "cd /opt/apps/${app} && \ |
| 78 | + slot_file=.deploy-slot; \ |
| 79 | + if [ -f \"\$slot_file\" ]; then \ |
| 80 | + slot=\$(cat \"\$slot_file\"); \ |
| 81 | + docker compose -p ${app}-\${slot} -f deploy/docker-compose.yml logs -f --tail 100; \ |
| 82 | + else \ |
| 83 | + docker compose -p ${app} -f deploy/docker-compose.yml logs -f --tail 100; \ |
| 84 | + fi" |
| 85 | +} |
| 86 | + |
| 87 | +cmd_health() { |
| 88 | + local target="${1:-all}" |
| 89 | + |
| 90 | + if [ "$target" = "all" ]; then |
| 91 | + require_config |
| 92 | + echo -e "${BOLD}Health Check — All Apps${NC}" |
| 93 | + echo "" |
| 94 | + ssh_cmd 'for dir in /opt/apps/*/; do |
| 95 | + app=$(basename "$dir") |
| 96 | + [[ "$app" == *-pr-* ]] && continue |
| 97 | + env_file="${dir}deploy/.env" |
| 98 | + if [ -f "$env_file" ]; then |
| 99 | + domain=$(grep "^APP_DOMAIN=" "$env_file" 2>/dev/null | cut -d= -f2 || echo "") |
| 100 | + if [ -n "$domain" ]; then |
| 101 | + if curl -sf "https://${domain}/health" >/dev/null 2>&1; then |
| 102 | + printf " %-20s %s\n" "$app" "healthy" |
| 103 | + else |
| 104 | + printf " %-20s %s\n" "$app" "UNHEALTHY" |
| 105 | + fi |
| 106 | + else |
| 107 | + printf " %-20s %s\n" "$app" "no domain configured" |
| 108 | + fi |
| 109 | + fi |
| 110 | + done' |
| 111 | + else |
| 112 | + require_config |
| 113 | + ssh_cmd "cd /opt/apps/${target} && \ |
| 114 | + domain=\$(grep '^APP_DOMAIN=' deploy/.env 2>/dev/null | cut -d= -f2 || echo ''); \ |
| 115 | + if [ -n \"\$domain\" ]; then \ |
| 116 | + curl -sf \"https://\${domain}/health\" && echo; \ |
| 117 | + else \ |
| 118 | + echo 'No APP_DOMAIN in deploy/.env'; \ |
| 119 | + fi" |
| 120 | + fi |
| 121 | +} |
| 122 | + |
| 123 | +cmd_create() { |
| 124 | + local app="${1:-}" |
| 125 | + if [ -z "$app" ]; then |
| 126 | + error "Usage: towlion create <app-name>" |
| 127 | + exit 1 |
| 128 | + fi |
| 129 | + |
| 130 | + if ! command -v gh &>/dev/null; then |
| 131 | + error "GitHub CLI (gh) is required. Install: https://cli.github.com" |
| 132 | + exit 1 |
| 133 | + fi |
| 134 | + |
| 135 | + info "Creating repo towlion/${app} from template..." |
| 136 | + gh repo create "towlion/${app}" --template towlion/app-template --public --clone |
| 137 | + |
| 138 | + require_config |
| 139 | + info "Provisioning credentials on server..." |
| 140 | + ssh_cmd "sudo bash /opt/platform/infrastructure/create-app-credentials.sh ${app}" |
| 141 | + |
| 142 | + info "Cloning repo on server..." |
| 143 | + ssh_cmd "cd /opt/apps && git clone https://github.com/towlion/${app}.git" |
| 144 | + |
| 145 | + info "Creating deploy/.env from template..." |
| 146 | + ssh_cmd "cd /opt/apps/${app} && cp deploy/env.template deploy/.env" |
| 147 | + |
| 148 | + echo "" |
| 149 | + info "App created: towlion/${app}" |
| 150 | + info "Next steps:" |
| 151 | + echo " 1. Edit deploy/.env on the server: ssh ${SERVER_USER}@${SERVER_HOST}" |
| 152 | + echo " 2. Set GitHub secrets: SERVER_HOST, SERVER_USER, SERVER_SSH_KEY, APP_DOMAIN" |
| 153 | + echo " 3. Push to main to trigger first deploy" |
| 154 | +} |
| 155 | + |
| 156 | +cmd_deploy() { |
| 157 | + local app="${1:-}" |
| 158 | + if [ -z "$app" ]; then |
| 159 | + error "Usage: towlion deploy <app-name>" |
| 160 | + exit 1 |
| 161 | + fi |
| 162 | + |
| 163 | + if ! command -v gh &>/dev/null; then |
| 164 | + error "GitHub CLI (gh) is required." |
| 165 | + exit 1 |
| 166 | + fi |
| 167 | + |
| 168 | + info "Triggering deploy workflow for towlion/${app}..." |
| 169 | + gh workflow run deploy.yml --repo "towlion/${app}" |
| 170 | + info "Deploy triggered. Watch with: gh run list --repo towlion/${app}" |
| 171 | +} |
| 172 | + |
| 173 | +cmd_restart() { |
| 174 | + require_config |
| 175 | + local app="${1:-}" |
| 176 | + if [ -z "$app" ]; then |
| 177 | + error "Usage: towlion restart <app-name>" |
| 178 | + exit 1 |
| 179 | + fi |
| 180 | + |
| 181 | + info "Restarting ${app}..." |
| 182 | + ssh_cmd "cd /opt/apps/${app} && \ |
| 183 | + slot_file=.deploy-slot; \ |
| 184 | + if [ -f \"\$slot_file\" ]; then \ |
| 185 | + slot=\$(cat \"\$slot_file\"); \ |
| 186 | + docker compose -p ${app}-\${slot} -f deploy/docker-compose.yml restart; \ |
| 187 | + else \ |
| 188 | + docker compose -p ${app} -f deploy/docker-compose.yml restart; \ |
| 189 | + fi" |
| 190 | + info "Restart complete" |
| 191 | +} |
| 192 | + |
| 193 | +cmd_backup() { |
| 194 | + require_config |
| 195 | + local target="${1:-all}" |
| 196 | + |
| 197 | + if [ "$target" = "all" ]; then |
| 198 | + info "Running backup for all databases..." |
| 199 | + ssh_cmd "bash /opt/platform/infrastructure/backup-postgres.sh" |
| 200 | + else |
| 201 | + info "Running backup for ${target}..." |
| 202 | + local db_name |
| 203 | + db_name=$(echo "$target" | tr '-' '_')_db |
| 204 | + ssh_cmd "docker compose -f /opt/platform/docker-compose.yml exec -T postgres \ |
| 205 | + pg_dump -U postgres -Fc ${db_name} > /data/backups/postgres/${db_name}_\$(date +%Y%m%d_%H%M%S).dump" |
| 206 | + info "Backup complete" |
| 207 | + fi |
| 208 | +} |
| 209 | + |
| 210 | +cmd_rotate() { |
| 211 | + require_config |
| 212 | + local app="${1:-}" |
| 213 | + if [ -z "$app" ]; then |
| 214 | + error "Usage: towlion rotate <app-name> [--type db|s3|all]" |
| 215 | + exit 1 |
| 216 | + fi |
| 217 | + shift |
| 218 | + local extra_args="$*" |
| 219 | + |
| 220 | + info "Rotating credentials for ${app}..." |
| 221 | + ssh_cmd "bash /opt/platform/infrastructure/rotate-credentials.sh ${app} ${extra_args}" |
| 222 | +} |
| 223 | + |
| 224 | +cmd_ssh() { |
| 225 | + require_config |
| 226 | + info "Connecting to ${SERVER_USER}@${SERVER_HOST}..." |
| 227 | + ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new "${SERVER_USER}@${SERVER_HOST}" |
| 228 | +} |
| 229 | + |
| 230 | +cmd_tunnel() { |
| 231 | + require_config |
| 232 | + local port="${1:-}" |
| 233 | + if [ -z "$port" ]; then |
| 234 | + error "Usage: towlion tunnel <port>" |
| 235 | + echo " Example: towlion tunnel 5432 (PostgreSQL)" |
| 236 | + echo " Example: towlion tunnel 3000 (Grafana)" |
| 237 | + exit 1 |
| 238 | + fi |
| 239 | + |
| 240 | + info "Opening SSH tunnel: localhost:${port} → ${SERVER_HOST}:${port}" |
| 241 | + info "Press Ctrl+C to close" |
| 242 | + ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new \ |
| 243 | + -L "${port}:localhost:${port}" -N "${SERVER_USER}@${SERVER_HOST}" |
| 244 | +} |
| 245 | + |
| 246 | +cmd_verify() { |
| 247 | + require_config |
| 248 | + info "Running server verification..." |
| 249 | + ssh_cmd "bash /opt/platform/infrastructure/verify-server.sh" |
| 250 | +} |
| 251 | + |
| 252 | +cmd_alerts() { |
| 253 | + require_config |
| 254 | + info "Checking alerts..." |
| 255 | + ssh_cmd ". /opt/platform/.env && bash /opt/platform/infrastructure/check-alerts.sh" |
| 256 | +} |
| 257 | + |
| 258 | +cmd_help() { |
| 259 | + echo -e "${BOLD}towlion${NC} v${VERSION} — Towlion platform CLI" |
| 260 | + echo "" |
| 261 | + echo -e "${BOLD}Usage:${NC} towlion <command> [options]" |
| 262 | + echo "" |
| 263 | + echo -e "${BOLD}Commands:${NC}" |
| 264 | + echo " status List all apps with container state and health" |
| 265 | + echo " logs <app> Tail app logs" |
| 266 | + echo " health [app|all] Check /health endpoints" |
| 267 | + echo " create <app> Create repo from template + provision credentials" |
| 268 | + echo " deploy <app> Trigger deploy workflow via GitHub Actions" |
| 269 | + echo " restart <app> Restart app containers" |
| 270 | + echo " backup [app|all] Run backup now" |
| 271 | + echo " rotate <app> Rotate credentials (--type db|s3|all)" |
| 272 | + echo " ssh SSH to server" |
| 273 | + echo " tunnel <port> SSH tunnel (e.g., towlion tunnel 5432)" |
| 274 | + echo " verify Run verify-server.sh" |
| 275 | + echo " alerts Run check-alerts.sh" |
| 276 | + echo " help Show this help" |
| 277 | + echo " version Show version" |
| 278 | + echo "" |
| 279 | + echo -e "${BOLD}Config:${NC} ${CONFIG_FILE}" |
| 280 | + echo " SERVER_HOST=<ip> Server IP address" |
| 281 | + echo " SERVER_USER=deploy SSH username (default: deploy)" |
| 282 | + echo " SSH_KEY_PATH=~/.ssh/id_rsa SSH key path" |
| 283 | +} |
| 284 | + |
| 285 | +cmd_version() { |
| 286 | + echo "towlion v${VERSION}" |
| 287 | +} |
| 288 | + |
| 289 | +# --- Main --- |
| 290 | + |
| 291 | +load_config |
| 292 | + |
| 293 | +COMMAND="${1:-help}" |
| 294 | +shift 2>/dev/null || true |
| 295 | + |
| 296 | +case "$COMMAND" in |
| 297 | + status) cmd_status "$@" ;; |
| 298 | + logs) cmd_logs "$@" ;; |
| 299 | + health) cmd_health "$@" ;; |
| 300 | + create) cmd_create "$@" ;; |
| 301 | + deploy) cmd_deploy "$@" ;; |
| 302 | + restart) cmd_restart "$@" ;; |
| 303 | + backup) cmd_backup "$@" ;; |
| 304 | + rotate) cmd_rotate "$@" ;; |
| 305 | + ssh) cmd_ssh "$@" ;; |
| 306 | + tunnel) cmd_tunnel "$@" ;; |
| 307 | + verify) cmd_verify "$@" ;; |
| 308 | + alerts) cmd_alerts "$@" ;; |
| 309 | + help) cmd_help ;; |
| 310 | + version) cmd_version ;; |
| 311 | + *) |
| 312 | + error "Unknown command: $COMMAND" |
| 313 | + echo "" |
| 314 | + cmd_help |
| 315 | + exit 1 |
| 316 | + ;; |
| 317 | +esac |
0 commit comments