diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9033418 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +dist +*.tsbuildinfo +.git +.github +.husky +docs +e2e +playwright-report +monitoring +*.md +!README.md +.env* +.eslintcache +coverage +.turbo diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b99311e..f098ce5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,8 @@ name: Deploy on: + push: + branches: [main] workflow_dispatch: inputs: environment: @@ -13,13 +15,20 @@ on: - production concurrency: - group: deploy-${{ github.event.inputs.environment }} + group: deploy-${{ inputs.environment || 'staging' }} cancel-in-progress: false +env: + REGISTRY: ghcr.io + REGISTRY_URL: ghcr.io/${{ github.repository_owner }} + jobs: - build: - name: Build Docker Images + build-api: + name: Build API Image runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout @@ -28,43 +37,239 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # TODO: Configure container registry login - # - name: Login to Container Registry - # uses: docker/login-action@v3 - # with: - # registry: ${{ secrets.REGISTRY_URL }} - # username: ${{ secrets.REGISTRY_USERNAME }} - # password: ${{ secrets.REGISTRY_PASSWORD }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - # TODO: Build and push Docker images - # - name: Build API image - # uses: docker/build-push-action@v6 - # with: - # context: . - # file: apps/api/Dockerfile - # push: true - # tags: ${{ secrets.REGISTRY_URL }}/goodgo-api:${{ github.sha }} - # cache-from: type=gha - # cache-to: type=gha,mode=max + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_URL }}/goodgo-api + tags: | + type=sha,prefix= + type=ref,event=branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - name: Placeholder - run: echo "Docker build steps TBD — configure registry and Dockerfiles first" + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/api/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=api + cache-to: type=gha,mode=max,scope=api - deploy: - name: Deploy to ${{ github.event.inputs.environment }} - needs: build + build-web: + name: Build Web Image runs-on: ubuntu-latest - environment: ${{ github.event.inputs.environment }} + permissions: + contents: read + packages: write steps: - # TODO: Deploy to Kubernetes - # - name: Configure kubectl - # uses: azure/setup-kubectl@v4 + - name: Checkout + uses: actions/checkout@v4 - # - name: Deploy - # run: | - # kubectl set image deployment/goodgo-api \ - # api=${{ secrets.REGISTRY_URL }}/goodgo-api:${{ github.sha }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Placeholder - run: echo "Deploy steps TBD — configure Kubernetes and environment secrets first" + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_URL }}/goodgo-web + tags: | + type=sha,prefix= + type=ref,event=branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push Web image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/web/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web + + build-ai: + name: Build AI Services Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_URL }}/goodgo-ai-services + tags: | + type=sha,prefix= + type=ref,event=branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push AI Services image + uses: docker/build-push-action@v6 + with: + context: ./libs/ai-services + file: libs/ai-services/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=ai + cache-to: type=gha,mode=max,scope=ai + + deploy-staging: + name: Deploy to Staging + needs: [build-api, build-web, build-ai] + if: github.event_name == 'push' || inputs.environment == 'staging' + runs-on: ubuntu-latest + environment: staging + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy to staging + env: + DEPLOY_HOST: ${{ secrets.STAGING_HOST }} + DEPLOY_USER: ${{ secrets.STAGING_USER }} + DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }} + IMAGE_TAG: ${{ github.sha }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + # Copy production compose and deploy + scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/" + scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/" + + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'DEPLOY_SCRIPT' + cd ~/goodgo + export IMAGE_TAG="${IMAGE_TAG}" + export REGISTRY_URL="${REGISTRY_URL}" + + # Login to GHCR + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # Pull new images + docker compose -f docker-compose.prod.yml pull api web ai-services + + # Rolling update — zero downtime + docker compose -f docker-compose.prod.yml up -d --no-deps --wait api + docker compose -f docker-compose.prod.yml up -d --no-deps --wait web + docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services + + # Run database migrations + docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy + + # Cleanup old images + docker image prune -f + DEPLOY_SCRIPT + + - name: Verify staging deployment + env: + STAGING_URL: ${{ secrets.STAGING_URL }} + run: | + for i in $(seq 1 10); do + if curl -sf "$STAGING_URL/health" > /dev/null 2>&1; then + echo "Staging deployment verified successfully" + exit 0 + fi + echo "Waiting for staging to be ready... ($i/10)" + sleep 10 + done + echo "Staging health check failed" + exit 1 + + deploy-production: + name: Deploy to Production + needs: [build-api, build-web, build-ai] + if: inputs.environment == 'production' + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy to production + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_USER }} + DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + IMAGE_TAG: ${{ github.sha }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/" + scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/" + + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'DEPLOY_SCRIPT' + cd ~/goodgo + export IMAGE_TAG="${IMAGE_TAG}" + export REGISTRY_URL="${REGISTRY_URL}" + + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + docker compose -f docker-compose.prod.yml pull api web ai-services + + # Rolling update with health checks + docker compose -f docker-compose.prod.yml up -d --no-deps --wait api + docker compose -f docker-compose.prod.yml up -d --no-deps --wait web + docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services + + docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy + + docker image prune -f + DEPLOY_SCRIPT + + - name: Verify production deployment + env: + PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }} + run: | + for i in $(seq 1 10); do + if curl -sf "$PRODUCTION_URL/health" > /dev/null 2>&1; then + echo "Production deployment verified successfully" + exit 0 + fi + echo "Waiting for production to be ready... ($i/10)" + sleep 10 + done + echo "Production health check failed" + exit 1 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..4dac4fa --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,48 @@ +# ---- Base ---- +FROM node:22-slim AS base +RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +WORKDIR /app + +# ---- Dependencies ---- +FROM base AS deps +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./ +COPY apps/api/package.json apps/api/ +COPY libs/mcp-servers/package.json libs/mcp-servers/ +COPY prisma/ prisma/ +RUN pnpm install --frozen-lockfile --filter @goodgo/api... + +# ---- Build ---- +FROM base AS build +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/libs/mcp-servers/node_modules ./libs/mcp-servers/node_modules +COPY tsconfig.base.json ./ +COPY prisma/ prisma/ +COPY libs/mcp-servers/ libs/mcp-servers/ +COPY apps/api/ apps/api/ +RUN pnpm --filter @goodgo/mcp-servers build 2>/dev/null || true +RUN cd apps/api && npx nest build +RUN npx prisma generate + +# ---- Production ---- +FROM node:22-slim AS production +RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=build /app/apps/api/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/apps/api/package.json ./package.json + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://localhost:3001/health').then(r => { if (!r.ok) throw 1 })" + +USER node + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/main"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..e7d3d5a --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,42 @@ +# ---- Base ---- +FROM node:22-slim AS base +RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +WORKDIR /app + +# ---- Dependencies ---- +FROM base AS deps +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./ +COPY apps/web/package.json apps/web/ +RUN pnpm install --frozen-lockfile --filter @goodgo/web... + +# ---- Build ---- +FROM base AS build +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY tsconfig.base.json ./ +COPY apps/web/ apps/web/ +RUN cd apps/web && npx next build + +# ---- Production ---- +FROM node:22-slim AS production +RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +COPY --from=build /app/apps/web/public ./public +COPY --from=build /app/apps/web/.next/standalone ./ +COPY --from=build /app/apps/web/.next/static ./.next/static + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://localhost:3000/api/health').then(r => { if (!r.ok) throw 1 }).catch(() => { process.exit(1) })" + +USER node + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "server.js"] diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 91ef62f..f83dccb 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + output: 'standalone', }; module.exports = nextConfig; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..01ed032 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,212 @@ +services: + api: + image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-api:${IMAGE_TAG:-latest} + container_name: goodgo-api + restart: unless-stopped + ports: + - '${API_PORT:-3001}:3001' + environment: + NODE_ENV: production + DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME} + REDIS_URL: redis://redis:6379 + TYPESENSE_HOST: typesense + TYPESENSE_PORT: 8108 + TYPESENSE_API_KEY: ${TYPESENSE_API_KEY} + JWT_SECRET: ${JWT_SECRET} + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} + AI_SERVICES_URL: http://ai-services:8000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + typesense: + condition: service_healthy + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/health').then(r => { if (!r.ok) throw 1 })"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - goodgo-net + + web: + image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-web:${IMAGE_TAG:-latest} + container_name: goodgo-web + restart: unless-stopped + ports: + - '${WEB_PORT:-3000}:3000' + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://api:3001} + depends_on: + api: + condition: service_healthy + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://localhost:3000').then(r => { if (!r.ok) throw 1 }).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - goodgo-net + + postgres: + image: postgis/postgis:16-3.4 + container_name: goodgo-postgres + restart: unless-stopped + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - goodgo-net + + redis: + image: redis:7-alpine + container_name: goodgo-redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + healthcheck: + test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - goodgo-net + + typesense: + image: typesense/typesense:27.1 + container_name: goodgo-typesense + restart: unless-stopped + environment: + TYPESENSE_API_KEY: ${TYPESENSE_API_KEY} + TYPESENSE_DATA_DIR: /data + volumes: + - typesense_data:/data + healthcheck: + test: ['CMD', 'curl', '-sf', 'http://localhost:8108/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + networks: + - goodgo-net + + minio: + image: minio/minio:latest + container_name: goodgo-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY} + volumes: + - minio_data:/data + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + networks: + - goodgo-net + + ai-services: + image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-ai-services:${IMAGE_TAG:-latest} + container_name: goodgo-ai-services + restart: unless-stopped + environment: + AI_DEBUG: 'false' + AI_LOG_LEVEL: info + healthcheck: + test: ['CMD', 'python', '-c', 'import httpx; httpx.get("http://localhost:8000/health").raise_for_status()'] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - goodgo-net + + prometheus: + image: prom/prometheus:v2.51.0 + container_name: goodgo-prometheus + restart: unless-stopped + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.retention.time=30d' + - '--web.enable-lifecycle' + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + healthcheck: + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy'] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - goodgo-net + + grafana: + image: grafana/grafana:10.4.1 + container_name: goodgo-grafana + restart: unless-stopped + ports: + - '${GRAFANA_PORT:-3002}:3000' + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_USERS_ALLOW_SIGN_UP: 'false' + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3002} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + depends_on: + prometheus: + condition: service_healthy + healthcheck: + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/api/health'] + interval: 15s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - goodgo-net + +volumes: + pgdata: + driver: local + redis_data: + driver: local + typesense_data: + driver: local + minio_data: + driver: local + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + goodgo-net: + driver: bridge + name: goodgo-net