commit fa31f3756a38947c7e1d370b914a3b23d4c0cc7f Author: Zeke Abshire Date: Sat Apr 11 20:21:55 2026 -0500 Init diff --git a/cleanup/action.yml b/cleanup/action.yml new file mode 100644 index 0000000..3677dad --- /dev/null +++ b/cleanup/action.yml @@ -0,0 +1,40 @@ +name: Cleanup Preview +description: Stop container and remove Pangolin resource + +inputs: + app-name: + required: true + description: Application name (used for image/container naming) + slug: + required: true + description: Branch slug to clean up + pangolin-api-url: + required: true + description: Base URL for the Pangolin API (https://api.pangolin.local) + pangolin-api-key: + required: true + description: Pangolin API key + pangolin-org-id: + required: true + description: Pangolin organization ID + +runs: + using: composite + steps: + - name: Remove Pangolin resource + shell: bash + env: + PANGOLIN_API_URL: ${{ inputs.pangolin-api-url }} + PANGOLIN_API_KEY: ${{ inputs.pangolin-api-key }} + PANGOLIN_ORG_ID: ${{ inputs.pangolin-org-id }} + run: | + bash ${{ github.action_path }}/../scripts/pangolin-delete.sh \ + --resource-name "${{ inputs.app-name }}-${{ inputs.slug }}" + + - name: Stop preview container + shell: bash + run: | + CONTAINER="${{ inputs.app-name }}-${{ inputs.slug }}" + docker stop "$CONTAINER" 2>/dev/null || true + docker rm "$CONTAINER" 2>/dev/null || true + docker rmi "${{ inputs.app-name }}:${{ inputs.slug }}" 2>/dev/null || true \ No newline at end of file diff --git a/deploy/action.yml b/deploy/action.yml new file mode 100644 index 0000000..f14b055 --- /dev/null +++ b/deploy/action.yml @@ -0,0 +1,85 @@ +name: Deploy Container +description: Build Docker image, deploy container, and register with Pangolin + +inputs: + app-name: + required: true + description: Application name (used for image/container naming) + tag: + required: true + description: Image/container tag (e.g., "production" or a branch slug) + port: + required: true + description: Host port to expose + internal-port: + required: false + default: "3000" + description: Port the app listens on inside the container + environment: + required: true + description: Node environment name ("production" or "preview") + subdomain: + required: true + description: Subdomain for Pangolin resource + target-ip: + required: true + description: Target IP for Pangolin + build-args: + required: false + default: "" + description: Extra docker build args (space-separated KEY=VALUE pairs) + pangolin-api-url: + required: true + description: Base URL for the Pangolin API (https://api.pangolin.local) + pangolin-api-key: + required: true + description: Pangolin API key + pangolin-org-id: + required: true + description: Pangolin organization ID + pangolin-domain-id: + required: true + description: Pangolin domain ID + pangolin-site-id: + required: true + description: Pangolin site ID + +runs: + using: composite + steps: + - name: Build Docker image + shell: bash + run: | + BUILD_ARGS="--build-arg APP_ENV=${{ inputs.environment }}" + for arg in ${{ inputs.build-args }}; do + BUILD_ARGS="$BUILD_ARGS --build-arg $arg" + done + docker build \ + $BUILD_ARGS \ + -t ${{ inputs.app-name }}:${{ inputs.tag }} \ + -f dockerfile . + + - name: Deploy container + shell: bash + run: | + bash ${{ github.action_path }}/../scripts/deploy.sh \ + --name "${{ inputs.app-name }}" \ + --tag "${{ inputs.tag }}" \ + --port "${{ inputs.port }}" \ + --internal-port "${{ inputs.internal-port }}" \ + --env "${{ inputs.environment }}" + + - name: Register Pangolin resource + shell: bash + env: + PANGOLIN_API_URL: ${{ inputs.pangolin-api-url }} + PANGOLIN_API_KEY: ${{ inputs.pangolin-api-key }} + PANGOLIN_ORG_ID: ${{ inputs.pangolin-org-id }} + PANGOLIN_DOMAIN_ID: ${{ inputs.pangolin-domain-id }} + PANGOLIN_SITE_ID: ${{ inputs.pangolin-site-id }} + run: | + bash ${{ github.action_path }}/../scripts/pangolin-upsert.sh \ + --subdomain "${{ inputs.subdomain }}" \ + --port "${{ inputs.port }}" \ + --resource-name "${{ inputs.app-name }}-${{ inputs.tag }}" \ + --target-ip "${{ inputs.target-ip }}" \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..d71a6b4 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="" +TAG="" +PORT="" +INTERNAL_PORT="3000" +ENV="" + +while [[ $# -gt 0 ]]; do +case $1 in + --name) APP_NAME="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --internal-port) INTERNAL_PORT="$2"; shift 2 ;; + --env) ENV="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; +esac +done + +CONTAINER="${APP_NAME}-${TAG}" + +echo "→ Deploying container: ${CONTAINER} on port ${PORT}" + +docker stop "${CONTAINER}" 2>/dev/null && docker rm "${CONTAINER}" 2>/dev/null || true + +ENV_FILE_ARG="" +if [[ -f "/opt/apps/${APP_NAME}/.env.${ENV}" ]]; then +ENV_FILE_ARG="--env-file /opt/apps/${APP_NAME}/.env.${ENV}" +fi + +docker run -d \ +--name "${CONTAINER}" \ +--restart unless-stopped \ +-p "0.0.0.0:${PORT}:${INTERNAL_PORT}" \ +-e NODE_ENV=production \ +-e PORT="${INTERNAL_PORT}" \ +${ENV_FILE_ARG} \ +"${APP_NAME}:${TAG}" + +echo "✓ Container ${CONTAINER} running on 0.0.0.0:${PORT}" \ No newline at end of file diff --git a/scripts/pangolin-delete.sh b/scripts/pangolin-delete.sh new file mode 100644 index 0000000..e5a015d --- /dev/null +++ b/scripts/pangolin-delete.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Remove a Pangolin resource by name +set -euo pipefail + +RESOURCE_NAME="" + +while [[ $# -gt 0 ]]; do + case $1 in + --resource-name) RESOURCE_NAME="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +API="${PANGOLIN_API_URL}/v1" +AUTH="Authorization: Bearer ${PANGOLIN_API_KEY}" + +echo "→ Pangolin delete: ${RESOURCE_NAME}" + +RESOURCE_ID=$(curl -sf \ + -H "${AUTH}" \ + "${API}/org/${PANGOLIN_ORG_ID}/resources?limit=1000" \ + | jq -r --arg name "${RESOURCE_NAME}" \ + '.data.resources[] | select(.name == $name) | .resourceId' \ + || echo "") + +if [[ -z "${RESOURCE_ID}" ]]; then + echo " No resource found with name '${RESOURCE_NAME}', skipping." + exit 0 +fi + +echo " Found resource ${RESOURCE_ID}, deleting…" + +curl -sf -X DELETE \ + -H "${AUTH}" \ + "${API}/resource/${RESOURCE_ID}" \ + > /dev/null + +echo "✓ Resource ${RESOURCE_ID} (${RESOURCE_NAME}) removed from Pangolin" \ No newline at end of file diff --git a/scripts/pangolin-upsert.sh b/scripts/pangolin-upsert.sh new file mode 100644 index 0000000..9b34034 --- /dev/null +++ b/scripts/pangolin-upsert.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Idempotently register an HTTP resource + target in Pangolin +set -euo pipefail + +SUBDOMAIN="" +PORT="" +RESOURCE_NAME="" +TARGET_IP="" + +while [[ $# -gt 0 ]]; do + case $1 in + --subdomain) SUBDOMAIN="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --resource-name) RESOURCE_NAME="$2"; shift 2 ;; + --target-ip) TARGET_IP="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +API="${PANGOLIN_API_URL}/v1" +AUTH="Authorization: Bearer ${PANGOLIN_API_KEY}" + +echo "→ Pangolin upsert: ${RESOURCE_NAME} → ${SUBDOMAIN} on port ${PORT}" + +# Check if resource already exists +EXISTING=$(curl -sf \ + -H "${AUTH}" \ + "${API}/org/${PANGOLIN_ORG_ID}/resources?limit=1000" \ + | jq -r --arg name "${RESOURCE_NAME}" \ + '.data.resources[] | select(.name == $name) | .resourceId' \ + || echo "") + +if [[ -n "${EXISTING}" ]]; then + echo " Resource already exists (id=${EXISTING}), updating…" + RESOURCE_ID="${EXISTING}" + + curl -sf -X POST \ + -H "${AUTH}" \ + -H "Content-Type: application/json" \ + -d "{\"subdomain\": \"${SUBDOMAIN}\"}" \ + "${API}/resource/${RESOURCE_ID}" \ + > /dev/null + + # Remove existing targets so we can re-register with potentially new port + TARGETS=$(curl -sf \ + -H "${AUTH}" \ + "${API}/resource/${RESOURCE_ID}/targets" \ + | jq -r '.data.targets[].targetId' || echo "") + + for TID in ${TARGETS}; do + curl -sf -X DELETE \ + -H "${AUTH}" \ + "${API}/target/${TID}" > /dev/null + echo " Removed old target ${TID}" + done +else + echo " Creating new resource…" + CREATE_RESP=$(curl -sf -X PUT \ + -H "${AUTH}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"${RESOURCE_NAME}\", + \"http\": true, + \"subdomain\": \"${SUBDOMAIN}\", + \"domainId\": \"${PANGOLIN_DOMAIN_ID}\", + \"protocol\": \"tcp\" + }" \ + "${API}/org/${PANGOLIN_ORG_ID}/resource") + + RESOURCE_ID=$(echo "${CREATE_RESP}" | jq -r '.data.resourceId') + FULL_DOMAIN=$(echo "${CREATE_RESP}" | jq -r '.data.fullDomain') + echo " Created resource ${RESOURCE_ID} → ${FULL_DOMAIN}" +fi + +# Add target +echo " Adding target localhost:${PORT} on site ${PANGOLIN_SITE_ID}…" +TARGET_RESP=$(curl -sf -X PUT \ + -H "${AUTH}" \ + -H "Content-Type: application/json" \ + -d "{ + \"ip\": \"${TARGET_IP}\", + \"port\": ${PORT}, + \"method\": \"http\", + \"siteId\": ${PANGOLIN_SITE_ID} + }" \ + "${API}/resource/${RESOURCE_ID}/target") + +TARGET_ID=$(echo "${TARGET_RESP}" | jq -r '.data.targetId') +echo "✓ Target ${TARGET_ID} registered for resource ${RESOURCE_ID}" \ No newline at end of file