feat: add initial multi-environment CI/CD pipeline POC #3

Merged
nietzshn merged 24 commits from staging into main 2026-06-02 22:31:09 -06:00
11 changed files with 1302 additions and 2 deletions
Showing only changes of commit 5d85b4b741 - Show all commits
+84
View File
@@ -0,0 +1,84 @@
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: 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
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest \
image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed ci-image:latest
+186
View File
@@ -0,0 +1,186 @@
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
run: |
set -euo pipefail
IMAGE_TAG="${{ needs.build-and-push.outputs.image_tag }}"
APP_VERSION="${{ needs.build-and-push.outputs.app_version }}"
eval $(ssh-agent -s)
echo "${{ secrets.DEPLOY_SSH_KEY }}" | ssh-add -
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# 1. Pasamos las variables como argumentos en el mismo orden
ssh ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_HOST }} bash -s \
"${{ env.REGISTRY_URL }}" \
"${{ env.IMAGE_NAME }}" \
"${IMAGE_TAG}" \
"${APP_VERSION}" \
"${{ gitea.actor }}" \
"${{ secrets.TOKEN }}" << 'EOF'
set -euo pipefail
# 2. Las recibimos dentro de la sesión remota
REGISTRY_URL=$1
IMAGE_NAME=$2
IMAGE_TAG=$3
APP_VERSION=$4
GITEA_ACTOR=$5
TOKEN=$6
echo "Saving current image tag for rollback..."
CURRENT_IMAGE=$(docker inspect cicd-prod --format '{{.Config.Image}}' 2>/dev/null || echo "")
echo "Pulling image..."
echo "$TOKEN" | docker login $REGISTRY_URL -u $GITEA_ACTOR --password-stdin
docker pull $REGISTRY_URL/$IMAGE_NAME:$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=${APP_VERSION} \
-e DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
$REGISTRY_URL/$IMAGE_NAME:$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
EOF
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
+121
View File
@@ -0,0 +1,121 @@
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
run: |
set -euo pipefail
IMAGE_TAG="${{ needs.build-and-push.outputs.image_tag }}"
eval $(ssh-agent -s)
echo "${{ secrets.DEPLOY_SSH_KEY }}" | ssh-add -
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# 1. Pasamos las variables como argumentos en el mismo orden
ssh ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_HOST }} bash -s \
"${{ env.REGISTRY_URL }}" \
"${{ env.IMAGE_NAME }}" \
"${IMAGE_TAG}" \
"${{ gitea.sha }}" \
"${{ gitea.actor }}" \
"${{ gitea.run_id }}" \
"${{ secrets.TOKEN }}" << 'EOF'
set -euo pipefail
# 2. Las recibimos dentro de la sesión remota
REGISTRY_URL=$1
IMAGE_NAME=$2
IMAGE_TAG=$3
GIT_SHA=$4
GITEA_ACTOR=$5
BUILD_NUMBER=$6
TOKEN=$7
# Variables locales del script
GIT_BRANCH="dev"
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Pulling image..."
echo "$TOKEN" | docker login $REGISTRY_URL -u $GITEA_ACTOR --password-stdin
docker pull $REGISTRY_URL/$IMAGE_NAME:$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-${GIT_SHA} \
-e GIT_COMMIT=${GIT_SHA} \
-e GIT_BRANCH=${GIT_BRANCH} \
-e BUILD_DATE=${BUILD_DATE} \
-e DEPLOY_TIME=${BUILD_DATE} \
-e BUILD_NUMBER=${BUILD_NUMBER} \
$REGISTRY_URL/$IMAGE_NAME:$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
EOF
+146
View File
@@ -0,0 +1,146 @@
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
run: |
set -euo pipefail
IMAGE_TAG="${{ needs.build-and-push.outputs.image_tag }}"
eval $(ssh-agent -s)
echo "${{ secrets.DEPLOY_SSH_KEY }}" | ssh-add -
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# 1. Pasamos las variables como argumentos en el mismo orden
ssh ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_HOST }} bash -s \
"${{ env.REGISTRY_URL }}" \
"${{ env.IMAGE_NAME }}" \
"${IMAGE_TAG}" \
"${{ gitea.sha }}" \
"${{ gitea.actor }}" \
"${{ gitea.run_id }}" \
"${{ secrets.TOKEN }}" << 'EOF'
set -euo pipefail
# 2. Las recibimos dentro de la sesión remota
REGISTRY_URL=$1
IMAGE_NAME=$2
IMAGE_TAG=$3
GIT_SHA=$4
GITEA_ACTOR=$5
BUILD_NUMBER=$6
TOKEN=$7
# Variables locales del script
GIT_BRANCH="staging"
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Pulling image..."
echo "$TOKEN" | docker login $REGISTRY_URL -u $GITEA_ACTOR --password-stdin
docker pull $REGISTRY_URL/$IMAGE_NAME:$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-${GIT_SHA} \
-e GIT_COMMIT=${GIT_SHA} \
-e GIT_BRANCH=${GIT_BRANCH} \
-e BUILD_DATE=${BUILD_DATE} \
-e DEPLOY_TIME=${BUILD_DATE} \
-e BUILD_NUMBER=${BUILD_NUMBER} \
$REGISTRY_URL/$IMAGE_NAME:$IMAGE_TAG
echo "Waiting for health check..."
HEALTHY=false
for i in $(seq 1 12); do
RESPONSE=$(curl -sf http://localhost:8082/health || echo "")
if [ -n "$RESPONSE" ]; then
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 "::notice::Staging smoke tests passed"
HEALTHY=true
break
fi
fi
sleep 5
done
if [ "$HEALTHY" = false ]; then
echo "::error::Staging smoke tests/health check failed"
exit 1
fi
EOF
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
+1
View File
@@ -0,0 +1 @@
opencode.json
+37
View File
@@ -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 && apk upgrade --no-cache libxml2
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;"]
+227 -2
View File
@@ -1,3 +1,228 @@
# multi-env-pipeline-poc # CI/CD Multi-Environment Pipeline POC
CI/CD Multi-Environment Strategy (PoC) Prueba de concepto de un pipeline CI/CD multi-ambiente con Gitea Actions, Docker y Nginx.
## 📌 Resumen de la Arquitectura
Implementación de una estrategia de Integración y Despliegue Continuo (CI/CD) para múltiples entornos (QA, Staging, Producción) alojada en un único servidor VPS (Debian/Ubuntu). La arquitectura utiliza **Gitea** (como control de versiones, Registry de contenedores y Runner de acciones), **Docker** para la contenerización de la aplicación estática, y un servidor **Nginx** en el host (Host Nginx) que actúa como Reverse Proxy y terminador SSL.
## 🏗️ Estrategia de Infraestructura y Red
Para aislar los entornos en el mismo servidor sin conflictos de red, se implementó el siguiente flujo:
* **Puertos Internos Dockerizados:** Cada entorno corre en un contenedor Docker independiente, exponiendo un puerto único a nivel de `localhost` en el VPS (ej. QA en `8081`, Staging en `8082`, Prod en `8083`).
* **Reverse Proxy:** El servidor Nginx principal del host escucha el puerto `80` y `443` (públicos). Mediante bloques `server_name`, intercepta las peticiones a subdominios específicos (`practicas.qa.kubistudio.cloud`, `practicas.staging.kubistudio.cloud`, `practicas.prod.kubistudio.cloud`) y hace un `proxy_pass` hacia el puerto interno correspondiente.
* **Terminación SSL:** La seguridad HTTPS se maneja exclusivamente en el Nginx del host utilizando **Certbot (Let's Encrypt)**. Los contenedores de Docker solo manejan tráfico HTTP interno, evitando colisiones en el puerto `443`.
## ⚙️ Inyección Dinámica de Variables (Frontend Estático)
Para cumplir con la premisa de *Build Once, Deploy Anywhere* en una aplicación estática (HTML/JS/CSS servida por Nginx), se solucionó el problema de las variables de entorno de la siguiente manera:
1. **Variables en el Pipeline:** El pipeline de Gitea Actions pasa variables de entorno (Commit SHA, Branch, App Env, Build Date) mediante la bandera `-e` en el comando `docker run`.
2. **Entrypoint Interceptor:** Se utiliza un script personalizado `docker-entrypoint.sh` en el contenedor.
3. **Generación Runtime:** Antes de que el Nginx del contenedor arranque, el script lee las variables de entorno de Linux y genera dinámicamente un archivo estático `/usr/share/nginx/html/env-config.js` inyectando el objeto `window.__ENV__`.
4. **Consumo en Cliente:** El archivo `index.html` carga este script de configuración antes de ejecutar la lógica de la aplicación, permitiendo que la interfaz cambie de aspecto y muestre la metadata correcta según el entorno (QA, Staging o Prod).
## 🚀 Flujo de las Pipelines de CD y Ramas
El despliegue está dividido en tres flujos principales basados en ramas:
```
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)
```
### Detalle por Entorno
* **QA (Entorno de Pruebas):**
* **Triggers:** Push a la rama `dev`.
* **Build & Push:** Construye la imagen Docker inyectando argumentos de compilación (`--build-arg` con el Git SHA y Build Date) y la sube al Gitea Container Registry con las etiquetas `qa-latest` y `sha-<hash>`.
* **Deploy (SSH):** Se conecta por SSH al VPS, detiene y elimina el contenedor anterior (`cicd-qa`), levanta el nuevo en el puerto `8081` con `APP_ENV=qa` y valida el despliegue con un health check.
* **Staging (Entorno de Pre-producción):**
* **Triggers:** Push a la rama `staging`.
* **Build & Push:** Genera la imagen etiquetada para staging (`staging-latest`).
* **Deploy (SSH):** Detiene el contenedor anterior (`cicd-staging`), levanta el nuevo en el puerto `8082` con `APP_ENV=staging` y realiza smoke tests comprobando la variable de entorno expuesta en el de salud.
* **Production (Entorno de Producción):**
* **Triggers:** Push a la rama `main`.
* **Build & Push:** Genera la imagen inmutable de producción (`production-latest`, `stable` y `sha-<hash>`).
* **Deploy (SSH):** Guarda la imagen actual para rollback, levanta el nuevo contenedor en el puerto `8083` con `APP_ENV=production`. Si el health check falla, realiza un **rollback automático** restaurando la imagen anterior.
## 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
```
+29
View File
@@ -0,0 +1,29 @@
#!/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
sed -i \
-e "s/\${APP_ENV:-development}/${APP_ENV}/g" \
-e "s/\${APP_VERSION:-0.0.0}/${APP_VERSION}/g" \
/etc/nginx/nginx.conf
exec "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -euo pipefail
curl -sf --max-time 3 http://localhost:80/health > /dev/null 2>&1 || exit 1
+58
View File
@@ -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;
}
}
}
+409
View File
@@ -0,0 +1,409 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kubistudio - Multi-Environment Pipeline POC</title>
<script src="/env-config.js"></script>
<style>
:root {
--accent: #F59E0B;
--accent-bg: rgba(245, 158, 11, 0.1);
--accent-border: rgba(245, 158, 11, 0.3);
--bg: #0f172a;
--card-bg: #1e293b;
--card-border: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--success: #10B981;
--danger: #ef4444;
--warning: #F59E0B;
--warning-bg: rgba(245, 158, 11, 0.15);
--warning-text: #fbbf24;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.env-warning {
background: var(--warning-bg);
border-bottom: 1px solid var(--accent-border);
color: var(--warning-text);
text-align: center;
padding: 8px 16px;
font-size: 0.875rem;
font-weight: 500;
display: none;
}
.env-warning.visible { display: block; }
.container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 24px;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
header {
text-align: center;
width: 100%;
}
.logo-area {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--accent);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
color: #0f172a;
flex-shrink: 0;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background: var(--accent-bg);
color: var(--accent);
border: 1px solid var(--accent-border);
margin-top: 8px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 32px;
width: 100%;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
.card-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 20px;
font-weight: 600;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.05);
}
.metric.hidden { display: none; }
.metric-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 600;
}
.metric-value {
font-size: 1rem;
font-weight: 500;
color: var(--text);
word-break: break-all;
}
.metric-value.code {
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.health-section {
width: 100%;
}
.health-status {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
border-radius: 10px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
font-size: 0.9rem;
}
.health-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
transition: background 0.3s;
}
.health-indicator.ok { background: var(--success); }
.health-indicator.fail { background: var(--danger); }
.health-detail {
color: var(--text-secondary);
font-size: 0.8rem;
margin-left: auto;
}
.health-error {
color: var(--danger);
font-size: 0.8rem;
}
footer {
text-align: center;
color: var(--text-muted);
font-size: 0.75rem;
padding: 16px 24px;
border-top: 1px solid var(--card-border);
}
@media (max-width: 480px) {
.container { padding: 16px; gap: 16px; }
.card { padding: 20px; }
.metrics-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric { padding: 10px 12px; }
.metric-value { font-size: 0.85rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<div id="envWarning" class="env-warning"></div>
<div class="container">
<header>
<div class="logo-area">
<div class="logo-icon" id="logoIcon">K</div>
<h1>Kubistudio Pipeline</h1>
</div>
<div class="badge" id="envBadge">⚙️ Cargando...</div>
</header>
<div class="card" id="mainCard">
<div class="card-title">Informaci&oacute;n del Ambiente</div>
<div class="metrics-grid" id="metricsGrid">
<div class="metric" id="metric-version">
<span class="metric-label">Versi&oacute;n</span>
<span class="metric-value" id="val-version"></span>
</div>
<div class="metric" id="metric-env">
<span class="metric-label">Ambiente</span>
<span class="metric-value" id="val-env"></span>
</div>
<div class="metric" id="metric-deploy">
<span class="metric-label">&Uacute;ltimo Deploy</span>
<span class="metric-value" id="val-deploy"></span>
</div>
<div class="metric hidden" id="metric-commit">
<span class="metric-label">Git Commit</span>
<span class="metric-value code" id="val-commit"></span>
</div>
<div class="metric hidden" id="metric-branch">
<span class="metric-label">Git Branch</span>
<span class="metric-value code" id="val-branch"></span>
</div>
<div class="metric hidden" id="metric-build">
<span class="metric-label">Build Number</span>
<span class="metric-value" id="val-build"></span>
</div>
<div class="metric hidden" id="metric-build-date">
<span class="metric-label">Build Date</span>
<span class="metric-value" id="val-build-date"></span>
</div>
</div>
</div>
<div class="health-section">
<div class="health-status">
<span class="health-indicator" id="healthDot"></span>
<span id="healthLabel">Verificando estado del servicio...</span>
<span class="health-detail" id="healthDetail"></span>
</div>
</div>
</div>
<footer>
&copy; <span id="year"></span> Kubistudio. All rights reserved.
</footer>
<script>
(function() {
var env = window.__ENV__ || {};
var appEnv = env.APP_ENV || 'development';
var appVersion = env.APP_VERSION || '0.0.0';
var buildDate = env.BUILD_DATE || '';
var gitCommit = env.GIT_COMMIT || '';
var gitBranch = env.GIT_BRANCH || '';
var deployTime = env.DEPLOY_TIME || '';
var buildNumber = env.BUILD_NUMBER || '';
var config = {
qa: {
accent: '#F59E0B',
badge: '\uD83E\uDDEA Ambiente de Pruebas',
warning: '\u26A0\uFE0F Este no es un ambiente de producci\u00F3n. Los datos pueden ser modificados o eliminados.',
showAll: true,
logoBg: '#F59E0B'
},
staging: {
accent: '#3B82F6',
badge: '\uD83D\uDD35 Ambiente de Staging',
warning: '\u26A0\uFE0F Release candidate &mdash; pre-producci\u00F3n. Verificar antes de liberar.',
showAll: false,
showCommit: true,
showBranch: false,
showBuild: false,
showBuildDate: true,
logoBg: '#3B82F6'
},
production: {
accent: '#10B981',
badge: '\u2705 Ambiente de Producci\u00F3n',
warning: '',
showAll: false,
showCommit: false,
showBranch: false,
showBuild: false,
showBuildDate: true,
logoBg: '#10B981'
}
};
var envKey = appEnv.toLowerCase();
var cfg = config[envKey] || config.qa;
document.documentElement.style.setProperty('--accent', cfg.accent);
document.documentElement.style.setProperty('--accent-bg', cfg.accent + '18');
document.documentElement.style.setProperty('--accent-border', cfg.accent + '4d');
var logoIcon = document.getElementById('logoIcon');
if (logoIcon) {
logoIcon.style.background = cfg.logoBg;
logoIcon.textContent = envKey.charAt(0).toUpperCase();
}
var badge = document.getElementById('envBadge');
if (badge) badge.textContent = cfg.badge;
var warning = document.getElementById('envWarning');
if (cfg.warning) {
warning.innerHTML = cfg.warning;
warning.classList.add('visible');
}
function setVal(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = val || '—';
}
setVal('val-version', appVersion);
setVal('val-env', appEnv.toUpperCase());
setVal('val-deploy', deployTime ? new Date(deployTime).toLocaleString() : '—');
setVal('val-commit', gitCommit);
setVal('val-branch', gitBranch);
setVal('val-build', buildNumber);
setVal('val-build-date', buildDate ? new Date(buildDate).toLocaleString() : '—');
function toggleMetric(id, show) {
var el = document.getElementById(id);
if (el) el.classList.toggle('hidden', !show);
}
if (cfg.showAll) {
toggleMetric('metric-commit', true);
toggleMetric('metric-branch', true);
toggleMetric('metric-build', true);
toggleMetric('metric-build-date', true);
} else {
toggleMetric('metric-commit', cfg.showCommit);
toggleMetric('metric-branch', cfg.showBranch);
toggleMetric('metric-build', cfg.showBuild);
toggleMetric('metric-build-date', cfg.showBuildDate);
}
var healthDot = document.getElementById('healthDot');
var healthLabel = document.getElementById('healthLabel');
var healthDetail = document.getElementById('healthDetail');
function checkHealth() {
fetch('/health')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
healthDot.className = 'health-indicator ok';
healthLabel.textContent = 'Servicio operativo';
healthDetail.textContent = data.env ? data.env.toUpperCase() + ' \u00B7 ' + data.version : '';
})
.catch(function(err) {
healthDot.className = 'health-indicator fail';
healthLabel.textContent = 'Error de conexi\u00F3n';
healthDetail.textContent = err.message;
});
}
checkHealth();
setInterval(checkHealth, 30000);
document.getElementById('year').textContent = new Date().getFullYear();
})();
</script>
</body>
</html>