diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..74a2094 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,150 @@ +name: Deploy Environment Monitoring to Production Environment + +on: + push: + branches: + - prod-env + +concurrency: + group: prod-monitoring-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: prod-runner + steps: + - name: Checkout Branch + uses: actions/checkout@v4 + + - name: Connect Runner to Overlay Network + run: | + docker network connect iklimco-net $(hostname) || true + + - name: Install Required Tools + run: | + sudo sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' /etc/apt/sources.list.d/ubuntu.sources || true + sudo sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' /etc/apt/sources.list || true + sudo sed -i 's|http://security.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' /etc/apt/sources.list.d/ubuntu.sources || true + sudo sed -i 's|http://security.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' /etc/apt/sources.list || true + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + sudo rm -f /etc/apt/sources.list.d/git-core-ubuntu-ppa*.list + sudo rm -f /etc/apt/sources.list.d/github_git-lfs.list + sudo apt-get update + sudo apt-get install -y gettext jq + + - name: Set up SSH Key and Add to known_hosts + run: | + mkdir -p ~/.ssh + echo "${{ secrets.STORAGEBOX_SSH_PRIV }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p 23 ${{ vars.STORAGEBOX_USER }}.your-storagebox.de >> ~/.ssh/known_hosts + + - name: Download Deploy Inputs + run: | + rm -f .env .env.secrets.swag + scp -P 23 ${{ vars.STORAGEBOX_USER }}@${{ vars.STORAGEBOX_USER }}.your-storagebox.de:prod/secrets/iklim.co/.env ./.env + scp -P 23 ${{ vars.STORAGEBOX_USER }}@${{ vars.STORAGEBOX_USER }}.your-storagebox.de:prod/secrets/iklim.co/.env.secrets.swag ./.env.secrets.swag + test -s .env + test -s .env.secrets.swag + + - name: Create Dozzle Auth Secret + env: + DOZZLE_USERS_YML_B64: ${{ secrets.DOZZLE_USERS_YML_B64 }} + DOZZLE_USERS_YML: ${{ secrets.DOZZLE_USERS_YML }} + run: | + set -euo pipefail + if [ -n "${DOZZLE_USERS_YML_B64:-}" ]; then + printf '%s' "$DOZZLE_USERS_YML_B64" | base64 -d > /tmp/dozzle-users.yml + elif [ -n "${DOZZLE_USERS_YML:-}" ]; then + printf '%s\n' "$DOZZLE_USERS_YML" > /tmp/dozzle-users.yml + else + echo "DOZZLE_USERS_YML_B64 or DOZZLE_USERS_YML secret is required" + exit 1 + fi + + grep -q '^users:' /tmp/dozzle-users.yml + docker service rm iklimco_dozzle || true + for i in $(seq 1 24); do + if ! docker service inspect iklimco_dozzle >/dev/null 2>&1; then + break + fi + echo "Waiting for old iklimco_dozzle service to be removed..." + sleep 5 + done + docker secret rm dozzle_users || true + docker secret create dozzle_users /tmp/dozzle-users.yml >/dev/null + shred -u /tmp/dozzle-users.yml || rm -f /tmp/dozzle-users.yml + + - name: Deploy Dozzle Stack + run: | + set -a; . ./.env; set +a + export IMAGE_DOZZLE="${IMAGE_DOZZLE:-amir20/dozzle:v10.6.6}" + export DOZZLE_USERS_SECRET_NAME=dozzle_users + + docker stack deploy \ + --resolve-image changed \ + -c docker-stack-service.yml \ + iklimco + + - name: Wait for Dozzle + run: | + for i in $(seq 1 36); do + REPLICAS=$(docker service ls --filter name=iklimco_dozzle --format "{{.Replicas}}" | head -1) + if echo "$REPLICAS" | awk -F'[/ ]' '$1>0 && $1==$2{found=1} END{exit !found}'; then + echo "Dozzle is ready: $REPLICAS" + exit 0 + fi + echo "Dozzle not ready yet (${REPLICAS:-missing}), waiting 5s..." + sleep 5 + done + docker service ps iklimco_dozzle || true + exit 1 + + - name: Configure SWAG Reverse Proxy + run: | + set -a; . ./.env; . ./.env.secrets.swag; set +a + export DOZZLE_SUBDOMAIN="${DOZZLE_SUBDOMAIN:-dozzle.iklim.co}" + envsubst '${DOZZLE_SUBDOMAIN}' < swag/dozzle.conf.tpl | docker run --rm -i \ + -v "${SWAG_SITE_CONFS_DIR}:/output" \ + alpine sh -c "cat > /output/dozzle.conf" + + SWAG_CTR=$(docker ps -q -f name=iklimco_swag 2>/dev/null | head -1) + if [ -n "$SWAG_CTR" ]; then + docker exec "$SWAG_CTR" nginx -t && docker exec "$SWAG_CTR" nginx -s reload + fi + + - name: Configure APISIX Reverse Proxy + run: | + set -a; . ./.env; set +a + export SPRING_PROFILES_ACTIVE=prod + export DOZZLE_SUBDOMAIN="${DOZZLE_SUBDOMAIN:-dozzle.iklim.co}" + /bin/bash init/apisix-dozzle.sh + + - name: Update DNS Record + run: | + set -a; . ./.env; . ./.env.secrets.swag; set +a + FLOATING_IP="${{ vars.PROD_FLOATING_IP }}" + DOMAIN="iklim.co" + RECORD="${DOZZLE_DNS_RECORD:-dozzle}" + + CURRENT=$(curl -s \ + -H "Authorization: sso-key ${GODADDY_KEY}:${GODADDY_SECRET}" \ + "https://api.godaddy.com/v1/domains/${DOMAIN}/records/A/${RECORD}" \ + 2>/dev/null | jq -r '.[0].data // empty' 2>/dev/null || true) + + if [ "$CURRENT" = "$FLOATING_IP" ]; then + echo "${RECORD}.${DOMAIN} -> ${FLOATING_IP} exists, skipping" + else + curl -sf -X PUT \ + -H "Authorization: sso-key ${GODADDY_KEY}:${GODADDY_SECRET}" \ + -H "Content-Type: application/json" \ + "https://api.godaddy.com/v1/domains/${DOMAIN}/records/A/${RECORD}" \ + -d "[{\"data\":\"${FLOATING_IP}\",\"ttl\":600}]" + echo "${RECORD}.${DOMAIN} -> ${FLOATING_IP} added/updated" + fi + + - name: Verify Deployment + run: | + docker service ps iklimco_dozzle \ + --filter "desired-state=running" \ + --format "table {{.Name}}\t{{.Node}}\t{{.CurrentState}}\t{{.Image}}" | head -20 diff --git a/docker-stack-service.yml b/docker-stack-service.yml new file mode 100644 index 0000000..09f861c --- /dev/null +++ b/docker-stack-service.yml @@ -0,0 +1,38 @@ +services: + dozzle: + image: ${IMAGE_DOZZLE:-amir20/dozzle:v10.6.6} + environment: + - DOZZLE_MODE=swarm + - DOZZLE_AUTH_PROVIDER=simple + - DOZZLE_NO_ANALYTICS=true + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + secrets: + - source: dozzle_users + target: /data/users.yml + mode: 0400 + networks: + - dozzle + - iklimco-net + deploy: + mode: global + restart_policy: + condition: any + delay: 5s + update_config: + parallelism: 1 + order: start-first + labels: + project: co.iklim + +secrets: + dozzle_users: + external: true + name: ${DOZZLE_USERS_SECRET_NAME:-dozzle_users} + +networks: + dozzle: + driver: overlay + attachable: true + iklimco-net: + external: true diff --git a/init/apisix-dozzle.sh b/init/apisix-dozzle.sh new file mode 100755 index 0000000..442b7a2 --- /dev/null +++ b/init/apisix-dozzle.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +PROFILE=${SPRING_PROFILES_ACTIVE:-prod} + +if [[ "$PROFILE" == "dev" ]]; then + APISIX_ADMIN_URL=http://${LAN_IP:-127.0.0.1}:9180/apisix/admin +else + APISIX_ADMIN_URL=http://apisix:9180/apisix/admin +fi + +API_KEY=${APISIX_ADMIN_KEY:?APISIX_ADMIN_KEY is required} +DOZZLE_HOST=${DOZZLE_SUBDOMAIN:-dozzle.iklim.co} +DOZZLE_NODE=${DOZZLE_NODE:-dozzle:8080} +ERRORS=0 + +call_api() { + local label="$1"; shift + local http_code + http_code=$(curl -sS -o /tmp/apisix_dozzle_resp.json -w "%{http_code}" "$@") + if [[ "$http_code" -ge 400 ]]; then + echo "ERROR: $label (HTTP $http_code)" + cat /tmp/apisix_dozzle_resp.json + echo + ERRORS=$((ERRORS + 1)) + fi +} + +until curl -sf -o /dev/null -H "X-API-KEY: $API_KEY" "$APISIX_ADMIN_URL/upstreams"; do + echo "APISIX not ready, retrying in 3s..." + sleep 3 +done + +HC='"checks":{"active":{"type":"http","http_path":"/","timeout":5,"healthy":{"interval":10,"successes":1},"unhealthy":{"interval":5,"http_failures":3}},"passive":{"healthy":{"http_statuses":[200,201,204,302],"successes":2},"unhealthy":{"http_statuses":[429,500,502,503,504],"http_failures":3,"tcp_failures":3}}}' + +if [[ "$PROFILE" != "dev" ]]; then + DOZZLE_ROUTE_PLUGINS=',"plugins":{"limit-count":{"count":120,"time_window":60,"key":"remote_addr","rejected_code":429,"policy":"local"}}' +else + DOZZLE_ROUTE_PLUGINS="" +fi + +call_api "upstream dozzle" -X PUT "$APISIX_ADMIN_URL/upstreams/dozzle-upstream" \ + -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \ + -d '{"name":"dozzle-upstream","type":"roundrobin","nodes":{"'"$DOZZLE_NODE"'":1},'"$HC"'}' + +call_api "service dozzle" -X PUT "$APISIX_ADMIN_URL/services/dozzle-service" \ + -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \ + -d '{"name":"dozzle-service","upstream_id":"dozzle-upstream","enable_websocket":true}' + +call_api "route dozzle" -X PUT "$APISIX_ADMIN_URL/routes/dozzle-route" \ + -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \ + -d '{"name":"dozzle-route","hosts":["'"$DOZZLE_HOST"'"],"uri":"/*","methods":["GET","POST","PUT","DELETE","PATCH","OPTIONS"],"service_id":"dozzle-service","enable_websocket":true'"$DOZZLE_ROUTE_PLUGINS"'}' + +if [ "$ERRORS" -gt 0 ]; then + echo "Dozzle APISIX init completed with $ERRORS error(s)." + exit 1 +fi + +echo "Dozzle APISIX route configured for https://${DOZZLE_HOST}" diff --git a/swag/dozzle.conf.tpl b/swag/dozzle.conf.tpl new file mode 100644 index 0000000..d0be2c2 --- /dev/null +++ b/swag/dozzle.conf.tpl @@ -0,0 +1,20 @@ +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name ${DOZZLE_SUBDOMAIN}; + + include /config/nginx/ssl.conf; + include /config/nginx/resolver.conf; + + client_max_body_size 0; + + location / { + include /config/nginx/proxy.conf; + include /config/nginx/resolver.conf; + set $upstream_app apisix; + set $upstream_port 9080; + set $upstream_proto http; + proxy_pass $upstream_proto://$upstream_app:$upstream_port; + } +} diff --git a/users.yml.example b/users.yml.example new file mode 100644 index 0000000..f48cddf --- /dev/null +++ b/users.yml.example @@ -0,0 +1,11 @@ +# Store the real file in Gitea secret DOZZLE_USERS_YML_B64 or DOZZLE_USERS_YML. +# Do not commit production password hashes. +# +# Example shape for Dozzle simple auth: +# +# users: +# admin: +# name: Admin +# email: admin@iklim.co +# password: "$2a$10$replace-with-bcrypt-hash" +# roles: none