diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d306521 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,136 @@ +name: CI Pipeline + +on: + push: + branches-ignore: + - 'renovate/**' + pull_request: + branches: + - dev + - staging + - main + +env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + +jobs: + lint: + name: HTML Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install html-validate + run: npm install -g html-validate + + - name: Validate HTML + run: | + set -euo pipefail + html-validate src/index.html || true + echo "::notice::HTML validation completed (non-blocking)" + + build: + name: Build Docker Image + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ci-buildx-${{ gitea.sha }} + restore-keys: | + ci-buildx- + + - name: Build image + run: | + set -euo pipefail + docker buildx build \ + --cache-from=type=local,src=/tmp/.buildx-cache \ + --cache-to=type=local,dest=/tmp/.buildx-cache-new,mode=max \ + --load \ + --build-arg APP_VERSION=ci-${{ gitea.sha }} \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + --build-arg GIT_COMMIT=${{ gitea.sha }} \ + --build-arg GIT_BRANCH=${{ gitea.ref_name }} \ + -t ci-image:latest \ + . + + - name: Verify image starts + run: | + set -euo pipefail + docker run -d --name ci-test \ + -e APP_ENV=ci \ + -e APP_VERSION=ci-${{ gitea.sha }} \ + -e BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e GIT_COMMIT=${{ gitea.sha }} \ + -e GIT_BRANCH=${{ gitea.ref_name }} \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -p 8080:80 \ + ci-image:latest + + for i in $(seq 1 12); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "::notice::Health check passed" + docker logs ci-test 2>&1 || true + docker stop ci-test + docker rm ci-test + exit 0 + fi + sleep 5 + done + + echo "::error::Health check failed after 60 seconds" + docker logs ci-test 2>&1 || true + docker stop ci-test + docker rm ci-test + exit 1 + + - name: Move cache + run: | + set -euo pipefail + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + needs: build + steps: + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ci-image:latest + format: table + exit-code: 1 + severity: HIGH,CRITICAL + ignore-unfixed: true + + summary: + name: Generate Summary + runs-on: ubuntu-latest + needs: [lint, build, security-scan] + if: always() + steps: + - name: Create summary + run: | + cat << 'SUMMARY' >> $GITEA_HOME/workflow/summary + ## CI Pipeline Results + + | Job | Status | + |-----|--------| + | Lint | ${{ needs.lint.result }} | + | Build | ${{ needs.build.result }} | + | Security Scan | ${{ needs.security-scan.result }} | + + **Commit:** ${{ gitea.sha }} + **Branch:** ${{ gitea.ref_name }} + SUMMARY diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..110d34e --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,167 @@ +name: Deploy Production + +on: + push: + branches: + - main + +env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + APP_ENV: production + +jobs: + build-and-push: + name: Build and Push + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + app_version: ${{ steps.meta.outputs.app_version }} + environment: + name: production + url: https://practicas.prod.kubistudio.cloud + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + run: | + set -euo pipefail + echo "${{ secrets.TOKEN }}" | docker login $REGISTRY_URL -u ${{ gitea.actor }} --password-stdin + + - name: Build and push + id: meta + run: | + set -euo pipefail + SHA_TAG="${{ gitea.sha }}" + PROD_TAG="production-latest" + STABLE_TAG="stable" + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + APP_VERSION="release-${{ gitea.sha }}" + + docker buildx build \ + --push \ + --build-arg APP_VERSION=${APP_VERSION} \ + --build-arg BUILD_DATE=${BUILD_DATE} \ + --build-arg GIT_COMMIT=${SHA_TAG} \ + --build-arg GIT_BRANCH=main \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:${PROD_TAG} \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:${STABLE_TAG} \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:sha-${SHA_TAG} \ + . + + echo "image_tag=${PROD_TAG}" >> $GITEA_OUTPUT + echo "app_version=${APP_VERSION}" >> $GITEA_OUTPUT + echo "::notice::Image pushed: ${REGISTRY_URL}/${IMAGE_NAME}:${PROD_TAG}" + + deploy: + name: Deploy to Production + runs-on: ubuntu-latest + needs: build-and-push + environment: + name: production + url: https://practicas.prod.kubistudio.cloud + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USERNAME }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + + echo "Saving current image tag for rollback..." + CURRENT_IMAGE=$(docker inspect cicd-prod --format '{{.Config.Image}}' 2>/dev/null || echo "") + + echo "Pulling image..." + echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY_URL }} -u ${{ gitea.actor }} --password-stdin + docker pull ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Stopping existing container..." + docker stop cicd-prod 2>/dev/null || true + docker rm cicd-prod 2>/dev/null || true + + echo "Starting new container..." + docker run -d \ + --name cicd-prod \ + --restart unless-stopped \ + -p 8083:80 \ + -e APP_ENV=production \ + -e APP_VERSION=${{ needs.build-and-push.outputs.app_version }} \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Waiting for health check..." + HEALTHY=false + for i in $(seq 1 12); do + if curl -sf http://localhost:8083/health > /dev/null 2>&1; then + HEALTHY=true + echo "::notice::Production health check passed" + break + fi + sleep 5 + done + + if [ "$HEALTHY" = false ]; then + echo "::error::Health check failed - rolling back" + + docker stop cicd-prod 2>/dev/null || true + docker rm cicd-prod 2>/dev/null || true + + if [ -n "$CURRENT_IMAGE" ]; then + docker run -d \ + --name cicd-prod \ + --restart unless-stopped \ + -p 8083:80 \ + -e APP_ENV=production \ + -e APP_VERSION=rollback \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + "$CURRENT_IMAGE" + + echo "::warning::Rollback completed to previous image: $CURRENT_IMAGE" + else + echo "::error::No previous image available for rollback" + fi + + exit 1 + fi + + release-notes: + name: Release Notes + runs-on: ubuntu-latest + needs: [build-and-push, deploy] + if: success() + steps: + - name: Generate release notes + run: | + set -euo pipefail + COMMITS_SINCE_LAST=$(git log --oneline --no-decorate ${{ gitea.sha }} -n 20 2>/dev/null || echo "No commit history available") + + cat << 'SUMMARY' >> $GITEA_HOME/workflow/summary + ## Production Deployment Successful :rocket: + + **Version:** ${{ needs.build-and-push.outputs.app_version }} + **Commit:** ${{ gitea.sha }} + **Image:** ${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:production-latest + **URL:** https://practicas.prod.kubistudio.cloud + + ### Recent Commits + ``` + SUMMARY + + git log --oneline --no-decorate -n 20 ${{ gitea.sha }} 2>/dev/null >> $GITEA_HOME/workflow/summary || true + + cat << 'SUMMARY' >> $GITEA_HOME/workflow/summary + ``` + + ### Rollback + If needed, rollback with: + ```bash + docker stop cicd-prod && docker rm cicd-prod + docker run -d --name cicd-prod --restart unless-stopped -p 8083:80 \${{ vars.REGISTRY_URL }}/\${{ vars.IMAGE_NAME }}:stable + ``` + SUMMARY diff --git a/.gitea/workflows/deploy-qa.yml b/.gitea/workflows/deploy-qa.yml new file mode 100644 index 0000000..0f27db8 --- /dev/null +++ b/.gitea/workflows/deploy-qa.yml @@ -0,0 +1,115 @@ +name: Deploy QA + +on: + push: + branches: + - dev + +env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + APP_ENV: qa + +jobs: + build-and-push: + name: Build and Push + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + run: | + set -euo pipefail + echo "${{ secrets.TOKEN }}" | docker login $REGISTRY_URL -u ${{ gitea.actor }} --password-stdin + + - name: Build and push + id: meta + run: | + set -euo pipefail + SHA_TAG="${{ gitea.sha }}" + QA_TAG="qa-latest" + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + docker buildx build \ + --push \ + --build-arg APP_VERSION=dev-${SHA_TAG} \ + --build-arg BUILD_DATE=${BUILD_DATE} \ + --build-arg GIT_COMMIT=${SHA_TAG} \ + --build-arg GIT_BRANCH=dev \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:${QA_TAG} \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:sha-${SHA_TAG} \ + . + + echo "image_tag=${QA_TAG}" >> $GITEA_OUTPUT + echo "::notice::Image pushed: ${REGISTRY_URL}/${IMAGE_NAME}:${QA_TAG}" + + deploy: + name: Deploy to QA + runs-on: ubuntu-latest + needs: build-and-push + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USERNAME }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + + echo "Pulling image..." + echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY_URL }} -u ${{ gitea.actor }} --password-stdin + docker pull ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Stopping existing container..." + docker stop cicd-qa 2>/dev/null || true + docker rm cicd-qa 2>/dev/null || true + + echo "Starting new container..." + docker run -d \ + --name cicd-qa \ + --restart unless-stopped \ + -p 8081:80 \ + -e APP_ENV=qa \ + -e APP_VERSION=dev-${{ gitea.sha }} \ + -e GIT_COMMIT=${{ gitea.sha }} \ + -e GIT_BRANCH=dev \ + -e BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e BUILD_NUMBER=${{ gitea.run_id }} \ + ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Waiting for health check..." + for i in $(seq 1 12); do + if curl -sf http://localhost:8081/health > /dev/null 2>&1; then + echo "::notice::QA deployment healthy" + exit 0 + fi + sleep 5 + done + + echo "::error::QA health check failed" + exit 1 + + notify: + name: Notification + runs-on: ubuntu-latest + needs: [build-and-push, deploy] + if: always() + steps: + - name: Write summary + run: | + cat << 'SUMMARY' >> $GITEA_HOME/workflow/summary + ## QA Deployment ${{ needs.deploy.result }} + + **Branch:** dev + **Commit:** ${{ gitea.sha }} + **Image:** ${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:qa-latest + **URL:** https://practicas.qa.kubistudio.cloud + SUMMARY diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..7cdd3a1 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,115 @@ +name: Deploy Staging + +on: + push: + branches: + - staging + +env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + APP_ENV: staging + +jobs: + build-and-push: + name: Build and Push + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + run: | + set -euo pipefail + echo "${{ secrets.TOKEN }}" | docker login $REGISTRY_URL -u ${{ gitea.actor }} --password-stdin + + - name: Build and push + id: meta + run: | + set -euo pipefail + SHA_TAG="${{ gitea.sha }}" + STAGING_TAG="staging-latest" + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + docker buildx build \ + --push \ + --build-arg APP_VERSION=staging-${SHA_TAG} \ + --build-arg BUILD_DATE=${BUILD_DATE} \ + --build-arg GIT_COMMIT=${SHA_TAG} \ + --build-arg GIT_BRANCH=staging \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:${STAGING_TAG} \ + -t ${REGISTRY_URL}/${IMAGE_NAME}:sha-${SHA_TAG} \ + . + + echo "image_tag=${STAGING_TAG}" >> $GITEA_OUTPUT + echo "::notice::Image pushed: ${REGISTRY_URL}/${IMAGE_NAME}:${STAGING_TAG}" + + deploy: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: build-and-push + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USERNAME }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + + echo "Pulling image..." + echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY_URL }} -u ${{ gitea.actor }} --password-stdin + docker pull ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Stopping existing container..." + docker stop cicd-staging 2>/dev/null || true + docker rm cicd-staging 2>/dev/null || true + + echo "Starting new container..." + docker run -d \ + --name cicd-staging \ + --restart unless-stopped \ + -p 8082:80 \ + -e APP_ENV=staging \ + -e APP_VERSION=staging-${{ gitea.sha }} \ + -e GIT_COMMIT=${{ gitea.sha }} \ + -e GIT_BRANCH=staging \ + -e BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e BUILD_NUMBER=${{ gitea.run_id }} \ + ${{ env.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.image_tag }} + + echo "Running smoke tests..." + RESPONSE=$(curl -sf http://localhost:8082/health) + echo "Health response: $RESPONSE" + + ENV_VALUE=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['env'])" 2>/dev/null || echo "unknown") + if [ "$ENV_VALUE" != "staging" ]; then + echo "::error::Smoke test failed: expected env=staging, got env=$ENV_VALUE" + exit 1 + fi + + echo "::notice::Staging smoke tests passed" + + notify: + name: Notification + runs-on: ubuntu-latest + needs: [build-and-push, deploy] + if: always() + steps: + - name: Write summary + run: | + cat << 'SUMMARY' >> $GITEA_HOME/workflow/summary + ## Staging Deployment ${{ needs.deploy.result }} + + **Branch:** staging + **Commit:** ${{ gitea.sha }} + **Image:** ${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:staging-latest + **URL:** https://practicas.staging.kubistudio.cloud + SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5db19bd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +opencode.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4bd1ee4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +ARG APP_VERSION=0.0.0 +ARG BUILD_DATE=unknown +ARG GIT_COMMIT=unknown +ARG GIT_BRANCH=unknown + +FROM nginx:alpine AS runtime + +ARG APP_VERSION +ARG BUILD_DATE +ARG GIT_COMMIT +ARG GIT_BRANCH + +LABEL org.opencontainers.image.title="cicd-multi-env-pipeline-poc" +LABEL org.opencontainers.image.description="Multi-environment CI/CD pipeline POC" +LABEL org.opencontainers.image.version="${APP_VERSION}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" +LABEL org.opencontainers.image.revision="${GIT_COMMIT}" +LABEL org.opencontainers.image.source="https://git.kubistudio.cloud/kubistudio/cicd-multi-env-pipeline-poc" + +RUN apk add --no-cache curl + +COPY nginx.conf /etc/nginx/nginx.conf +COPY src/ /usr/share/nginx/html/ +COPY healthcheck.sh /usr/local/bin/healthcheck.sh + +RUN chmod +x /usr/local/bin/healthcheck.sh + +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD /usr/local/bin/healthcheck.sh + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 124873d..8e597d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,223 @@ -# multi-env-pipeline-poc +# CI/CD Multi-Environment Pipeline POC -CI/CD Multi-Environment Strategy (PoC) \ No newline at end of file +Prueba de concepto de un pipeline CI/CD multi-ambiente con Gitea Actions, Docker y Nginx. + +## Arquitectura + +``` + feature/* + | + PR + | + ┌───┴───┐ + │ dev │ ──(push)──► CI Pipeline ──► Deploy QA + └───┬───┘ (lint, build, (puerto 8081) + │ security scan) + PR docker registry + │ │ + ┌───┴───┐ │ + │staging│ ──(push)────────┼──► Deploy Staging + └───┬───┘ │ (puerto 8082) + │ │ + PR │ + │ │ + ┌───┴───┐ │ + │ main │ ──(push)────────┴──► Deploy Production + └───────┘ (aprobación) (puerto 8083) +``` + +## Ambientes + +| Ambiente | URL | Puerto | Rama | Trigger | +|-----------|------------------------------------------|--------|---------|---------------| +| QA | https://practicas.qa.kubistudio.cloud | 8081 | `dev` | Push autom. | +| Staging | https://practicas.staging.kubistudio.cloud | 8082 | `staging` | Push autom. | +| Production| https://practicas.prod.kubistudio.cloud | 8083 | `main` | Push + aprob. | + +## Configuración en Gitea + +### Secretos (Settings > Secrets) + +| Secreto | Descripción | +|--------------------|---------------------------------------------------| +| `TOKEN` | Token de Gitea con permisos de escritura al registry | +| `DEPLOY_SSH_KEY` | Clave SSH privada para acceder al servidor de deploy | +| `DEPLOY_USERNAME` | Usuario SSH para conexión al servidor | +| `DEPLOY_HOST` | Host/IP del servidor de deploy | + +### Variables (Settings > Variables) + +| Variable | Descripción | Ejemplo | +|------------------|------------------------------------------------------|------------------------------------------------| +| `REGISTRY_URL` | URL del Gitea Container Registry | `git.kubistudio.cloud` | +| `IMAGE_NAME` | Nombre de la imagen en el registry | `kubistudio/cicd-multi-env-pipeline-poc` | + +## Flujo de Trabajo + +1. Crear rama `feature/*` desde `dev` +2. Desarrollar y hacer commit +3. Abrir PR a `dev` → CI ejecuta lint, build, security scan +4. Merge a `dev` → CI + Deploy automático a QA +5. PR de `dev` → `staging` → Deploy automático a Staging +6. PR de `staging` → `main` → Requiere aprobación → Deploy a Producción + +## Ejecución Local + +### Con docker-compose + +```yaml +version: '3.8' + +services: + cicd-qa: + build: . + ports: + - "8081:80" + environment: + APP_ENV: qa + APP_VERSION: dev-local + GIT_COMMIT: local + GIT_BRANCH: dev + BUILD_DATE: ${BUILD_DATE:-unknown} + DEPLOY_TIME: ${DEPLOY_TIME:-unknown} + BUILD_NUMBER: "0" + + cicd-staging: + build: . + ports: + - "8082:80" + environment: + APP_ENV: staging + APP_VERSION: staging-local + GIT_COMMIT: local + GIT_BRANCH: staging + BUILD_DATE: ${BUILD_DATE:-unknown} + DEPLOY_TIME: ${DEPLOY_TIME:-unknown} + BUILD_NUMBER: "0" + + cicd-prod: + build: . + ports: + - "8083:80" + environment: + APP_ENV: production + APP_VERSION: 1.0.0 + DEPLOY_TIME: ${DEPLOY_TIME:-unknown} +``` + +### Manual + +```bash +# Build +docker build \ + --build-arg APP_VERSION=1.0.0 \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \ + --build-arg GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ + -t cicd-poc:latest . + +# Run +docker run -d \ + --name cicd-qa \ + -p 8081:80 \ + -e APP_ENV=qa \ + -e APP_VERSION=dev-local \ + -e GIT_COMMIT=$(git rev-parse --short HEAD) \ + -e GIT_BRANCH=dev \ + -e BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -e BUILD_NUMBER="1" \ + cicd-poc:latest + +# Test +curl http://localhost:8081/health +``` + +## Variables de Entorno del Contenedor + +| Variable | Obligatorio | Default | Descripción | +|-----------------|-------------|---------------|---------------------------------------| +| `APP_ENV` | No | `development` | Ambiente: qa, staging, production | +| `APP_VERSION` | No | `0.0.0` | Versión de la aplicación | +| `BUILD_DATE` | No | *(vacío)* | Fecha ISO del build | +| `GIT_COMMIT` | No | *(vacío)* | SHA corto del commit | +| `GIT_BRANCH` | No | *(vacío)* | Rama de git | +| `DEPLOY_TIME` | No | *(vacío)* | Timestamp del deploy | +| `BUILD_NUMBER` | No | *(vacío)* | Número de build de la pipeline | + +## Rollback Manual + +### Production + +```bash +ssh user@deploy-host + +# Rollback a stable tag +docker stop cicd-prod && docker rm cicd-prod +docker run -d \ + --name cicd-prod \ + --restart unless-stopped \ + -p 8083:80 \ + -e APP_ENV=production \ + -e APP_VERSION=rollback \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + registry-url/image-name:stable + +# O con un SHA específico +docker run -d \ + --name cicd-prod \ + --restart unless-stopped \ + -p 8083:80 \ + -e APP_ENV=production \ + -e APP_VERSION=rollback \ + -e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + registry-url/image-name:sha-SHA_DEL_COMMIT +``` + +### QA / Staging + +```bash +# QA +docker stop cicd-qa && docker rm cicd-qa +docker run -d --name cicd-qa -p 8081:80 ... registry-url/image-name:sha-SHA_ANTERIOR + +# Staging +docker stop cicd-staging && docker rm cicd-staging +docker run -d --name cicd-staging -p 8082:80 ... registry-url/image-name:sha-SHA_ANTERIOR +``` + +## Endpoints + +| Endpoint | Descripción | +|-----------|--------------------------------------| +| `/` | Aplicación web | +| `/health` | Health check (JSON) | + +### Ejemplo `/health` + +```json +{ + "status": "ok", + "env": "production", + "version": "release-a1b2c3d", + "timestamp": "2024-01-15T14:23:00+00:00" +} +``` + +## Estructura del Proyecto + +``` +. +├── src/ +│ └── index.html # App web (UI completa self-contained) +├── .gitea/workflows/ +│ ├── ci.yml # CI: lint + build + security scan +│ ├── deploy-qa.yml # Deploy a QA (push a dev) +│ ├── deploy-staging.yml # Deploy a Staging (push a staging) +│ └── deploy-prod.yml # Deploy a Producción (push a main + aprobación) +├── docker-entrypoint.sh # Entrypoint que genera env-config.js en runtime +├── Dockerfile # Multi-stage build +├── healthcheck.sh # Script de health check +├── nginx.conf # Configuración de nginx +└── README.md # Este archivo +``` diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7c8f8d5 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -euo pipefail + +APP_ENV="${APP_ENV:-development}" +APP_VERSION="${APP_VERSION:-0.0.0}" +BUILD_DATE="${BUILD_DATE:-}" +GIT_COMMIT="${GIT_COMMIT:-}" +GIT_BRANCH="${GIT_BRANCH:-}" +DEPLOY_TIME="${DEPLOY_TIME:-}" +BUILD_NUMBER="${BUILD_NUMBER:-}" + +cat > /usr/share/nginx/html/env-config.js << EOF +window.__ENV__ = { + APP_ENV: "${APP_ENV}", + APP_VERSION: "${APP_VERSION}", + BUILD_DATE: "${BUILD_DATE}", + GIT_COMMIT: "${GIT_COMMIT}", + GIT_BRANCH: "${GIT_BRANCH}", + DEPLOY_TIME: "${DEPLOY_TIME}", + BUILD_NUMBER: "${BUILD_NUMBER}" +}; +EOF + +exec "$@" diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100644 index 0000000..2ad4a75 --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -euo pipefail + +curl -sf --max-time 3 http://localhost:80/health > /dev/null 2>&1 || exit 1 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..46c3dd2 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,58 @@ +worker_processes auto; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + + gzip on; + gzip_comp_level 6; + gzip_min_length 256; + gzip_proxied any; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location = /health { + default_type application/json; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + return 200 '{"status":"ok","env":"${APP_ENV:-development}","version":"${APP_VERSION:-0.0.0}","timestamp":"$time_iso8601"}'; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, must-revalidate"; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location = /favicon.ico { + log_not_found off; + access_log off; + } + + location = /robots.txt { + log_not_found off; + access_log off; + } + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..3a7ac0a --- /dev/null +++ b/src/index.html @@ -0,0 +1,408 @@ + + +
+ + +