diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..617f9ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +**/node_modules +**/dist +**/.next +**/*.tsbuildinfo +*.log +.env +.env.* +infrastructure/certs +infrastructure/agent-releases +infrastructure/software-releases +agents/forge-agent/forge-agent +apps/web/.env* +.turbo +pnpm-debug.log* \ No newline at end of file diff --git a/infrastructure/deploy/deploy.env.example b/infrastructure/deploy/deploy.env.example new file mode 100644 index 0000000..77f0186 --- /dev/null +++ b/infrastructure/deploy/deploy.env.example @@ -0,0 +1,20 @@ +# Fleet Forge deployer environment +# Werte beim docker run als -e übergeben bzw. bei Unraid im Template setzen. + +# --- Git / Polling ---------------------------------------------------------- +GIT_REMOTE=https://git.pepe44.dev/Pepe44DEV/fleetforge.git +GIT_USER=Pepe44DEV +GIT_TOKEN= +GIT_BRANCH=master +POLL_INTERVAL=300 + +# --- Host / Ports ----------------------------------------------------------- +HOST_IP=10.0.0.15 +API_PORT=3005 # HTTP-Port, den die Agenten als server_url nutzen +HTTPS_PORT=5443 # Web/UI via https://10.0.0.15:5443 +HTTP_PORT=5080 + +# --- App secrets ------------------------------------------------------------ +# Wird gegen den externen Postgres auf 10.0.0.15 verwendet (DB "FleetForge"). +DATABASE_URL=postgresql://admin:Start01!@10.0.0.15:5432/FleetForge?schema=public +JWT_SECRET= \ No newline at end of file diff --git a/infrastructure/deploy/deploy.sh b/infrastructure/deploy/deploy.sh new file mode 100755 index 0000000..d66e77d --- /dev/null +++ b/infrastructure/deploy/deploy.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -uo pipefail + +# ============================================================================ +# Fleet Forge deployer +# Polls the git repo, rebuilds and redeploys the app containers on a single +# docker host (tested on Unraid). The deployer only orchectrates: the actual +# app containers (api, worker, web, nginx) run on the HOST docker daemon via +# the mounted docker socket. +# +# Required mounts: +# /var/run/docker.sock:/var/run/docker.sock +# /src:/app/src persistent git checkout + state +# /certs:/certs TLS certificates (rw, generated on demand) +# /nginx:/cfg rendered nginx config (rw) +# ============================================================================ + +log() { echo "[deployer] $(date '+%F %T') $*"; } +die() { log "ERROR: $*"; exit 1; } + +# --- Environment (with sane defaults) --------------------------------------- +GIT_REMOTE="${GIT_REMOTE:-https://git.pepe44.dev/Pepe44DEV/fleetforge.git}" +GIT_USER="${GIT_USER:-Pepe44DEV}" +GIT_TOKEN="${GIT_TOKEN:-}" +GIT_BRANCH="${GIT_BRANCH:-master}" +POLL_INTERVAL="${POLL_INTERVAL:-300}" +HOST_IP="${HOST_IP:-10.0.0.15}" +API_PORT="${API_PORT:-3005}" +HTTPS_PORT="${HTTPS_PORT:-5443}" +HTTP_PORT="${HTTP_PORT:-5080}" +NETWORK="${NETWORK:-fleetforge-net}" + +DATABASE_URL="${DATABASE_URL:-}" +JWT_SECRET="${JWT_SECRET:-}" + +SRC=/app/src +STATE_FILE="$SRC/.git/ff-deployed-commit" +NGINX_SRC=/etc/fleetforge/nginx.conf +NGINX_DEST=/cfg/fleetforge.conf +CERT_DIR=/certs + +# Basic checks ---------------------------------------------------------------- +[ -z "$GIT_TOKEN" ] && die "GIT_TOKEN ist nicht gesetzt" +[ -z "$DATABASE_URL" ] && die "DATABASE_URL ist nicht gesetzt" +[ -z "$JWT_SECRET" ] && die "JWT_SECRET ist nicht gesetzt" +command -v docker >/dev/null || die "docker CLI fehlt" +docker info >/dev/null 2>&1 || die "docker socket nicht erreichbar" + +AUTH_URL=$(echo "$GIT_REMOTE" | sed "s#https://#https://$GIT_USER:$GIT_TOKEN@#") +CORS_ORIGINS="https://$HOST_IP:$HTTPS_PORT,http://$HOST_IP:$API_PORT" + +# --- Git --------------------------------------------------------------------- +clone_or_pull() { + if [ ! -d "$SRC/.git" ]; then + log "initial clone: $GIT_REMOTE ($GIT_BRANCH)" + git clone --depth 1 --branch "$GIT_BRANCH" --single-branch "$AUTH_URL" "$SRC" + else + git -C "$SRC" fetch origin "$GIT_BRANCH" + git -C "$SRC" reset --hard "origin/$GIT_BRANCH" + git -C "$SRC" clean -fd + fi + git -C "$SRC" rev-parse "origin/$GIT_BRANCH" +} + +# --- Shared docker networking / config --------------------------------------- +ensure_network() { + docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" +} + +ensure_certs() { + if [ ! -f "$CERT_DIR/fleetforge.pem" ] || [ ! -f "$CERT_DIR/fleetforge-key.pem" ]; then + log "generating self-signed certificate for $HOST_IP" + mkdir -p "$CERT_DIR" + openssl req -x509 -nodes -newkey rsa:2048 -days 3650 -sha256 \ + -keyout "$CERT_DIR/fleetforge-key.pem" \ + -out "$CERT_DIR/fleetforge.pem" \ + -subj "/CN=fleetforge.local" \ + -addext "subjectAltName=IP:$HOST_IP,DNS:localhost,DNS:fleetforge.local,DNS:*.local" + fi + chmod 644 "$CERT_DIR/fleetforge.pem" + chmod 600 "$CERT_DIR/fleetforge-key.pem" + cp "$NGINX_SRC" "$NGINX_DEST" +} + +# --- Build --------------------------------------------------------------------- +build_images() { + log "building images (api builder + migrations first)" + docker build -f "$SRC/infrastructure/docker/Dockerfile.api" --target builder \ + -t fleetforge/api:builder "$SRC" + [ $? -eq 0 ] || die "api builder build failed" + + log "running migrations + seed" + docker run --rm -e "DATABASE_URL=$DATABASE_URL" fleetforge/api:builder \ + sh -c "cd /app/apps/api && pnpm migrate && pnpm seed" + [ $? -eq 0 ] || die "migrate/seed failed" + + log "building api runner image" + docker build -f "$SRC/infrastructure/docker/Dockerfile.api" --target runner \ + -t fleetforge/api:latest "$SRC" + [ $? -eq 0 ] || die "api build failed" + + log "building worker image" + docker build -f "$SRC/infrastructure/docker/Dockerfile.worker" \ + -t fleetforge/worker:latest "$SRC" + [ $? -eq 0 ] || die "worker build failed" + + log "building web image (NEXT_PUBLIC_API_URL=$CORS_ORIGINS)" + docker build --build-arg "NEXT_PUBLIC_API_URL=https://$HOST_IP:$HTTPS_PORT/api" \ + -f "$SRC/infrastructure/docker/Dockerfile.web" \ + -t fleetforge/web:latest "$SRC" + [ $? -eq 0 ] || die "web build failed" +} + +# --- Run ----------------------------------------------------------------------- +run_containers() { + log "stopping old containers" + for c in ff-api ff-worker ff-web ff-nginx; do + docker rm -f "$c" >/dev/null 2>&1 || true + done + + log "starting api (http $API_PORT for agents)" + docker run -d --name ff-api --restart unless-stopped --network "$NETWORK" \ + -p "$API_PORT:3000" \ + -e NODE_ENV=production \ + -e "DATABASE_URL=$DATABASE_URL" \ + -e "JWT_SECRET=$JWT_SECRET" \ + -e "CORS_ORIGINS=$CORS_ORIGINS" \ + fleetforge/api:latest + + log "starting worker" + docker run -d --name ff-worker --restart unless-stopped --network "$NETWORK" \ + -e NODE_ENV=production \ + -e "DATABASE_URL=$DATABASE_URL" \ + fleetforge/worker:latest + + log "starting web" + docker run -d --name ff-web --restart unless-stopped --network "$NETWORK" \ + -e NODE_ENV=production \ + fleetforge/web:latest + + log "starting nginx reverse proxy (https $HTTPS_PORT, http $HTTP_PORT)" + docker run -d --name ff-nginx --restart unless-stopped --network "$NETWORK" \ + -p "$HTTPS_PORT:443" \ + -p "$HTTP_PORT:80" \ + -v "$CERT_DIR:/etc/nginx/certs:ro" \ + -v "$NGINX_DEST:/etc/nginx/conf.d/fleetforge.conf:ro" \ + nginx:1.27-alpine +} + +# --- Health --------------------------------------------------------------------- +wait_for_health() { + local tries=${1:-30} + for i in $(seq 1 "$tries"); do + if curl -fsS "http://$HOST_IP:$API_PORT/api/health" >/dev/null 2>&1; then + log "API healthy after ${i} attempts" + return 0 + fi + sleep 5 + done + log "WARNING: API health check failed after $tries attempts" + return 1 +} + +# --- Deploy ---------------------------------------------------------------------- +deploy() { + build_images + ensure_network + ensure_certs + run_containers + wait_for_health || return 1 + log "deployment complete: https://$HOST_IP:$HTTPS_PORT" + return 0 +} + +# --------------------------------------------------------------------------------- +log "Fleet Forge deployer started (poll every ${POLL_INTERVAL}s, branch $GIT_BRANCH)" +mkdir -p "$SRC" "$CERT_DIR" "/cfg" + +while true; do + if ! HEAD=$(clone_or_pull 2>>/dev/stderr); then + log "git update failed, retrying in ${POLL_INTERVAL}s" + sleep "$POLL_INTERVAL" + continue + fi + + LAST="" + [ -f "$STATE_FILE" ] && LAST=$(cat "$STATE_FILE") + + if [ -z "$LAST" ] || [ "$HEAD" != "$LAST" ]; then + log "new commit detected: $HEAD (was: ${LAST:-none})" + if deploy; then + echo "$HEAD" > "$STATE_FILE" + else + log "deploy FAILED for $HEAD, will retry next poll" + fi + else + log "no changes (HEAD=$HEAD), sleeping ${POLL_INTERVAL}s" + fi + + sleep "$POLL_INTERVAL" +done \ No newline at end of file diff --git a/infrastructure/deploy/deployer.Dockerfile b/infrastructure/deploy/deployer.Dockerfile new file mode 100644 index 0000000..757cde1 --- /dev/null +++ b/infrastructure/deploy/deployer.Dockerfile @@ -0,0 +1,18 @@ +FROM alpine:3.21 + +RUN apk add --no-cache \ + docker-cli \ + git \ + bash \ + openssl \ + curl \ + coreutils \ + findutils \ + ca-certificates \ + tzdata + +COPY deploy.sh /usr/local/bin/deploy.sh +COPY nginx.conf /etc/fleetforge/nginx.conf +RUN chmod +x /usr/local/bin/deploy.sh + +ENTRYPOINT ["/usr/local/bin/deploy.sh"] \ No newline at end of file diff --git a/infrastructure/deploy/nginx.conf b/infrastructure/deploy/nginx.conf new file mode 100644 index 0000000..3284b88 --- /dev/null +++ b/infrastructure/deploy/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + + ssl_certificate /etc/nginx/certs/fleetforge.pem; + ssl_certificate_key /etc/nginx/certs/fleetforge-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 200m; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + + location /api/ { + proxy_pass http://ff-api:3000; + proxy_http_version 1.1; + 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; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://ff-web:3001; + proxy_http_version 1.1; + 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; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + } +} \ No newline at end of file diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 68dc6a2..859e8c5 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -6,14 +6,14 @@ FROM base AS deps COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ COPY apps/web/package.json ./apps/web/ COPY packages/shared/package.json ./packages/shared/ -COPY packages/ui/package.json ./packages/ui/ RUN pnpm install --frozen-lockfile || pnpm install FROM base AS builder +ARG NEXT_PUBLIC_API_URL=http://localhost:3000/api +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules -COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules COPY . . RUN pnpm --filter @fleetforge/shared build RUN pnpm --filter @fleetforge/web build