diff --git a/.gitea/workflows/_test-caller.yaml b/.gitea/workflows/_test-caller.yaml new file mode 100644 index 0000000..5062ff7 --- /dev/null +++ b/.gitea/workflows/_test-caller.yaml @@ -0,0 +1,12 @@ +name: TEST - verificacion de reusable workflow + composite action + +on: + push: + branches: + - main + +jobs: + call-reusable: + uses: devops/platform-templates/pipelines/_test-reusable.yaml@main + with: + greeting: "hola desde el caller" diff --git a/devsecops-snippets/cosign-sign-verify-step/action.yml b/devsecops-snippets/cosign-sign-verify-step/action.yml new file mode 100644 index 0000000..d05f939 --- /dev/null +++ b/devsecops-snippets/cosign-sign-verify-step/action.yml @@ -0,0 +1,68 @@ +name: "Cosign — firmar y verificar por digest" +description: >- + Firma la imagen por digest (no por tag) y verifica esa firma como + smoke test, en el mismo job. --tlog-upload=false: sin publicar en el + transparency log público de Sigstore (registry privado, no tiene + sentido anunciar públicamente qué se firmó — ver + docs/devsecops/cosign.md en apps-registry para el razonamiento + completo de esta decisión). + IMPORTANTE: un composite action no puede leer `secrets` directamente + — el workflow que lo llama tiene que pasarlos como `with:` (ver + ejemplo en el README de este snippet). + +inputs: + image_name: + description: "Nombre de la imagen sin tag/digest (ej. gitea.cruzcloud.net/devops/mi-app)." + required: true + digest: + description: "Digest sha256:... a firmar (el output del paso que hace docker push)." + required: true + public_key_path: + description: "Ruta al cosign.pub commiteado en el repo (para el smoke test de verificación)." + required: true + cosign_private_key: + description: "Contenido de la llave privada de Cosign (pasar como \\${{ secrets.COSIGN_PRIVATE_KEY }} desde el caller)." + required: true + cosign_password: + description: "Password de la llave privada (pasar como \\${{ secrets.COSIGN_PASSWORD }} desde el caller)." + required: true + cosign_version: + description: "Versión de Cosign a instalar." + required: false + default: "3.1.3" + +runs: + using: composite + steps: + - name: Instalar Cosign + shell: bash + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/sigstore/cosign/releases/download/v${{ inputs.cosign_version }}/cosign-linux-amd64" \ + -o /tmp/cosign + chmod +x /tmp/cosign + /tmp/cosign version + + - name: Firmar imagen (Cosign) — por digest + shell: bash + env: + COSIGN_PRIVATE_KEY: ${{ inputs.cosign_private_key }} + COSIGN_PASSWORD: ${{ inputs.cosign_password }} + run: | + set -euo pipefail + /tmp/cosign sign \ + --key env://COSIGN_PRIVATE_KEY \ + --use-signing-config=false \ + --tlog-upload=false \ + --yes \ + "${{ inputs.image_name }}@${{ inputs.digest }}" + + - name: Verificar firma (smoke test) + shell: bash + run: | + set -euo pipefail + /tmp/cosign verify \ + --key "${{ inputs.public_key_path }}" \ + --insecure-ignore-tlog=true \ + "${{ inputs.image_name }}@${{ inputs.digest }}" diff --git a/devsecops-snippets/gitleaks-step/action.yml b/devsecops-snippets/gitleaks-step/action.yml new file mode 100644 index 0000000..a760831 --- /dev/null +++ b/devsecops-snippets/gitleaks-step/action.yml @@ -0,0 +1,52 @@ +name: "Gitleaks — escaneo de secretos" +description: >- + Escanea el árbol de archivos (no el historial de git) buscando + secretos commiteados. Bloquea (exit 1) si encuentra algo. Extraído + literal de los 3 pipelines reales del lab (frontend, commerce-backend, + docs-portal) — idéntico en los tres salvo el "source". + +inputs: + source: + description: "Directorio a escanear (ej. workloads/mi-app)." + required: true + version: + description: "Versión de Gitleaks a instalar." + required: false + default: "8.21.2" + +runs: + using: composite + steps: + - name: Instalar Gitleaks + shell: bash + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${{ inputs.version }}/gitleaks_${{ inputs.version }}_linux_x64.tar.gz" \ + -o /tmp/gitleaks.tar.gz + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + chmod +x /tmp/gitleaks + /tmp/gitleaks version + + # --no-git: escanea el árbol de archivos del checkout, no el log de + # commits. Un scan histórico completo se corre aparte, manual, no + # en cada push/PR (ver README de este snippet). + - name: Escanear secretos en el árbol de archivos + shell: bash + run: | + set -euo pipefail + /tmp/gitleaks detect \ + --source="${{ inputs.source }}" \ + --no-git \ + --redact \ + --report-format=json \ + --report-path=gitleaks-report.json \ + --exit-code=1 + + - name: Publicar reporte de Gitleaks + if: always() + uses: actions/upload-artifact@v3 + with: + name: gitleaks-report + path: gitleaks-report.json + if-no-files-found: ignore diff --git a/devsecops-snippets/semgrep-step/action.yml b/devsecops-snippets/semgrep-step/action.yml new file mode 100644 index 0000000..fe0027f --- /dev/null +++ b/devsecops-snippets/semgrep-step/action.yml @@ -0,0 +1,82 @@ +name: "Semgrep — SAST" +description: >- + Escaneo estático de código, modo auditoría (no bloquea) por default — + mismo criterio que los 3 pipelines reales: primera vuelta para + revisar hallazgos en conjunto antes de decidir qué reglas deberían + bloquear. Corre nativo en un venv, no como contenedor aparte — el + runner de Gitea ya ejecuta cada job dentro de un contenedor propio + (sibling containers hablando con el Docker del host, no + Docker-in-Docker), así que un "docker run -v" desde acá monta rutas + que no existen en el host real y falla con "read-only file system". + +inputs: + target: + description: "Directorio a escanear." + required: true + configs: + description: >- + Rulesets de Semgrep separados por coma, ej. + "p/typescript,p/react,p/nextjs,p/security-audit". Elegí el + combo según tu stack real — no copies el mismo set para todo + (ver README de este snippet, tabla de qué usa cada arquetipo). + required: true + semgrep_version: + description: "Versión de Semgrep a instalar." + required: false + default: "1.173.0" + fail_on_findings: + description: "'true' para bloquear el pipeline si hay hallazgos. Default 'false' (auditoría)." + required: false + default: "false" + +runs: + using: composite + steps: + - name: Instalar Semgrep + shell: bash + run: | + set -euo pipefail + python3 -m venv /tmp/semgrep-venv + /tmp/semgrep-venv/bin/pip install --quiet "semgrep==${{ inputs.semgrep_version }}" + /tmp/semgrep-venv/bin/semgrep --version + + - name: Escaneo SAST (Semgrep) + shell: bash + run: | + set -euo pipefail + + CONFIG_ARGS=() + IFS=',' read -ra CONFIGS <<< "${{ inputs.configs }}" + for cfg in "${CONFIGS[@]}"; do + CONFIG_ARGS+=(--config="${cfg}") + done + + ERROR_FLAG=() + if [ "${{ inputs.fail_on_findings }}" = "true" ]; then + ERROR_FLAG=(--error) + fi + + /tmp/semgrep-venv/bin/semgrep scan \ + "${CONFIG_ARGS[@]}" \ + "${ERROR_FLAG[@]}" \ + --json \ + --output=semgrep-report.json \ + "${{ inputs.target }}" + + echo "=== Resumen Semgrep ===" + /tmp/semgrep-venv/bin/python -c " + import json + data = json.load(open('semgrep-report.json')) + results = data.get('results', []) + print(f'Hallazgos: {len(results)}') + for r in results: + print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\") + " + + - name: Publicar reporte de Semgrep + if: always() + uses: actions/upload-artifact@v3 + with: + name: semgrep-report + path: semgrep-report.json + if-no-files-found: ignore diff --git a/devsecops-snippets/syft-sbom-step/action.yml b/devsecops-snippets/syft-sbom-step/action.yml new file mode 100644 index 0000000..4daa472 --- /dev/null +++ b/devsecops-snippets/syft-sbom-step/action.yml @@ -0,0 +1,50 @@ +name: "Syft — SBOM" +description: >- + Genera el SBOM (CycloneDX + SPDX) de la imagen que ya pasó el gate de + CRITICAL de Trivy — describe exactamente qué paquetes (y en qué + versión) quedaron adentro. CycloneDX es el formato más usado por + herramientas de consulta/alertas de CVEs nuevas. + +inputs: + image_ref: + description: "Referencia completa imagen:tag a describir." + required: true + artifact_suffix: + description: "Sufijo para el nombre del artifact publicado (ej. la versión)." + required: true + syft_version: + description: "Versión de Syft a instalar." + required: false + default: "1.51.0" + +runs: + using: composite + steps: + - name: Instalar Syft + shell: bash + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/anchore/syft/releases/download/v${{ inputs.syft_version }}/syft_${{ inputs.syft_version }}_linux_amd64.tar.gz" \ + -o /tmp/syft.tar.gz + tar -xzf /tmp/syft.tar.gz -C /tmp syft + chmod +x /tmp/syft + /tmp/syft version + + - name: Generar SBOM (Syft) + shell: bash + run: | + set -euo pipefail + /tmp/syft "${{ inputs.image_ref }}" \ + -o cyclonedx-json=sbom.cdx.json \ + -o spdx-json=sbom.spdx.json + + - name: Publicar SBOM + if: always() + uses: actions/upload-artifact@v3 + with: + name: sbom-${{ inputs.artifact_suffix }} + path: | + sbom.cdx.json + sbom.spdx.json + if-no-files-found: ignore diff --git a/devsecops-snippets/trivy-iac-step/action.yml b/devsecops-snippets/trivy-iac-step/action.yml new file mode 100644 index 0000000..ff995a5 --- /dev/null +++ b/devsecops-snippets/trivy-iac-step/action.yml @@ -0,0 +1,47 @@ +name: "Trivy IaC — escaneo de manifiestos Kubernetes" +description: >- + Escanea manifiestos Kubernetes buscando misconfiguraciones (root, + falta de resource limits, falta de probes, etc). Informativo por + default (no bloquea) — mismo criterio que los 3 pipelines reales del + lab mientras se revisan los hallazgos antes de decidir qué debería + bloquear. + +inputs: + target: + description: "Archivo o directorio de manifiestos a escanear." + required: true + trivy_version: + description: "Versión de Trivy a instalar (compartida con trivy-image-step si corrés ambos en el mismo job)." + required: false + default: "0.74.0" + fail_on_findings: + description: "'true' para bloquear el pipeline si hay hallazgos. Default 'false' (auditoría)." + required: false + default: "false" + +runs: + using: composite + steps: + - name: Instalar Trivy + shell: bash + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/aquasecurity/trivy/releases/download/v${{ inputs.trivy_version }}/trivy_${{ inputs.trivy_version }}_Linux-64bit.tar.gz" \ + -o /tmp/trivy.tar.gz + tar -xzf /tmp/trivy.tar.gz -C /tmp trivy + chmod +x /tmp/trivy + /tmp/trivy version + + - name: Escanear manifiestos Kubernetes (Trivy IaC) + shell: bash + run: | + set -euo pipefail + EXIT_CODE=0 + if [ "${{ inputs.fail_on_findings }}" = "true" ]; then + EXIT_CODE=1 + fi + /tmp/trivy config \ + --severity CRITICAL,HIGH,MEDIUM \ + --exit-code "${EXIT_CODE}" \ + "${{ inputs.target }}" diff --git a/devsecops-snippets/trivy-image-step/action.yml b/devsecops-snippets/trivy-image-step/action.yml new file mode 100644 index 0000000..1efd299 --- /dev/null +++ b/devsecops-snippets/trivy-image-step/action.yml @@ -0,0 +1,65 @@ +name: "Trivy imagen — CVE de la imagen construida" +description: >- + Escanea la imagen Docker ya construida y cargada localmente (load: + true en el build, no hace falta push todavía). CRITICAL bloquea el + pipeline; HIGH es informativo. --timeout 15m0s siempre — el default + de Trivy (5m) no alcanza bajo carga real del host (visto en vivo en + commerce-backend, "context deadline exceeded" a los 4m52s). + +inputs: + image_ref: + description: "Referencia completa imagen:tag a escanear (ej. \\${{ env.IMAGE_NAME }}:\\${{ steps.vars.outputs.VERSION }})." + required: true + trivy_version: + description: "Versión de Trivy a instalar." + required: false + default: "0.74.0" + ignorefile: + description: >- + Ruta a un .trivyignore con excepciones documentadas (opcional). + Cada excepción debe tener justificación y fecha en el propio + archivo — no es un mecanismo para silenciar sin revisar. Ver + devsecops-snippets/trivy-image-step/README.md. + required: false + default: "" + +runs: + using: composite + steps: + - name: Instalar Trivy + shell: bash + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/aquasecurity/trivy/releases/download/v${{ inputs.trivy_version }}/trivy_${{ inputs.trivy_version }}_Linux-64bit.tar.gz" \ + -o /tmp/trivy.tar.gz + tar -xzf /tmp/trivy.tar.gz -C /tmp trivy + chmod +x /tmp/trivy + /tmp/trivy version + + - name: Escanear imagen (Trivy) — CRITICAL bloquea + shell: bash + run: | + set -euo pipefail + IGNOREFILE_ARGS=() + if [ -n "${{ inputs.ignorefile }}" ]; then + IGNOREFILE_ARGS=(--ignorefile "${{ inputs.ignorefile }}") + fi + /tmp/trivy image \ + --severity CRITICAL \ + --exit-code 1 \ + --ignore-unfixed \ + --timeout 15m0s \ + "${IGNOREFILE_ARGS[@]}" \ + "${{ inputs.image_ref }}" + + - name: Escanear imagen (Trivy) — HIGH informativo + shell: bash + run: | + set -euo pipefail + /tmp/trivy image \ + --severity HIGH \ + --exit-code 0 \ + --ignore-unfixed \ + --timeout 15m0s \ + "${{ inputs.image_ref }}" diff --git a/dockerfiles/nextjs.Dockerfile b/dockerfiles/nextjs.Dockerfile new file mode 100644 index 0000000..7625444 --- /dev/null +++ b/dockerfiles/nextjs.Dockerfile @@ -0,0 +1,111 @@ +# Basado en el Dockerfile real de ecommerce-frontend (Next.js, output +# "standalone"). Tu next.config.* necesita "output: 'standalone'" para +# que este Dockerfile funcione -- sin eso, .next/standalone no existe +# y el build de la imagen falla en el "test -f" del stage builder. +# +# ARGs a completar por tu app: +# NODE_IMAGE -- versión exacta de Node, fijá una concreta. +# APP_PORT -- puerto de escucha (Next.js con HOSTNAME/PORT del +# runtime standalone). Default 3000; el frontend real +# del lab lo corre en 80 vía este mismo ARG. + +# syntax=docker/dockerfile:1.7 + +ARG NODE_IMAGE=node:24.18.0-bookworm-slim +ARG APP_PORT=3000 + +# ============================================================ +# 1. Dependencias +# ============================================================ +FROM ${NODE_IMAGE} AS deps + +WORKDIR /app + +ENV NODE_ENV=development \ + NEXT_TELEMETRY_DISABLED=1 \ + NPM_CONFIG_AUDIT=false \ + NPM_CONFIG_FUND=false + +COPY package.json package-lock.json ./ + +RUN --mount=type=cache,target=/root/.npm,sharing=private \ + npm ci \ + --include=prod \ + --include=dev \ + --no-audit \ + --no-fund \ + --foreground-scripts + +# ============================================================ +# 2. Compilación Next.js +# ============================================================ +FROM ${NODE_IMAGE} AS builder + +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + NODE_OPTIONS=--max-old-space-size=2048 + +COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json ./ +COPY . . + +RUN --mount=type=cache,target=/app/.next/cache,sharing=private \ + npm run build + +# Falla temprano y con un mensaje claro si next.config.* no tiene +# "output: 'standalone'" -- mejor que un COPY silencioso más abajo +# fallando con "no existe el archivo". +RUN test -f /app/.next/standalone/server.js \ + && test -d /app/.next/static \ + || { echo "ERROR: falta 'output: standalone' en next.config.*"; exit 1; } + +# ============================================================ +# 3. Runtime de producción +# ============================================================ +FROM ${NODE_IMAGE} AS runner + +ARG APP_PORT +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=${APP_PORT} + +# setcap: solo hace falta si APP_PORT < 1024 (un puerto "privilegiado" +# que normalmente requiere root para bindear). Si tu APP_PORT es 3000 +# o cualquier valor >= 1024, podés borrar el setcap y libcap2-bin. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libcap2-bin \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 1001 nodejs \ + && useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin nextjs \ + && setcap 'cap_net_bind_service=+ep' /usr/local/bin/node + +# El runtime solo ejecuta "node server.js" -- nunca npm/npx/corepack. +# Sacarlos reduce lo que Trivy escanea al set real de producción. +RUN rm -rf \ + /usr/local/lib/node_modules/npm \ + /usr/local/lib/node_modules/corepack \ + /usr/local/bin/npm \ + /usr/local/bin/npx \ + /usr/local/bin/corepack + +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE ${APP_PORT} + +# CAMBIAR: /api/health tiene que existir en tu app (una route handler +# de Next.js que devuelva 200). Si no la tenés todavía, es la primera +# ruta a agregar -- este healthcheck y el readinessProbe/livenessProbe +# de k8s-manifests/base/deployment.yaml asumen que existe. +HEALTHCHECK --interval=30s --timeout=8s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + +CMD ["node", "server.js"] diff --git a/dockerfiles/node-api.Dockerfile b/dockerfiles/node-api.Dockerfile new file mode 100644 index 0000000..da99c8a --- /dev/null +++ b/dockerfiles/node-api.Dockerfile @@ -0,0 +1,94 @@ +# Basado en el Dockerfile real de commerce-backend (Medusa v2 / Express). +# Multi-stage: build con devDependencies, runtime solo con lo necesario +# para correr -- sin compilador, sin devDependencies, sin npm/npx. +# +# ARGs a completar por tu app (todos tienen un default razonable): +# NODE_IMAGE -- versión exacta de Node. Fijala a una versión concreta +# (no "node:22-slim"), así un rebuild no te cambia la +# versión de Node por sorpresa un día cualquiera. +# APP_PORT -- puerto en el que tu app escucha. +# START_CMD -- cómo arrancar tu app en runtime. Por default invoca +# "node dist/index.js" -- CAMBIALO al entrypoint real de +# tu build. No uses "npm start"/"npx algo": el runtime +# de esta imagen NO tiene npm (ver más abajo, por qué). + +# syntax=docker/dockerfile:1.7 + +ARG NODE_IMAGE=node:22.18.0-bookworm-slim +ARG APP_PORT=3000 + +############################################################# +# Builder +############################################################# +FROM ${NODE_IMAGE} AS builder + +WORKDIR /app + +ENV CI=true \ + NODE_ENV=development \ + NPM_CONFIG_UPDATE_NOTIFIER=false + +COPY package.json package-lock.json* .npmrc* ./ + +# --legacy-peer-deps: sacalo si tu proyecto no lo necesita. Medusa v2 sí +# lo necesita hoy (conflictos de peer deps conocidos del ecosistema). +RUN --mount=type=cache,id=node-api-npm-cache,target=/root/.npm \ + npm ci \ + --legacy-peer-deps \ + --prefer-offline \ + --no-audit \ + --no-fund + +# CAMBIAR: copiá acá los archivos/carpetas fuente reales de tu app +# (tsconfig, src/, config específico). Estos son solo un ejemplo. +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +RUN npm prune --omit=dev \ + && npm cache clean --force + +############################################################# +# Runtime +############################################################# +FROM ${NODE_IMAGE} + +ARG APP_PORT +ENV NODE_ENV=production \ + PORT=${APP_PORT} \ + NPM_CONFIG_UPDATE_NOTIFIER=false + +# El CLI global de npm que trae la imagen base node:*-slim arrastra su +# propia copia de "tar" con historial de CVEs — no hace falta en +# runtime (nunca corrés npm/npx acá, solo "node algo.js"). Borrarlo +# reduce lo que Trivy tiene que escanear al set real que corre en +# producción. +# +# OJO si tu app usa un init container que corre "npx " contra +# esta misma imagen (ej. migraciones): eso se rompe si borrás npm acá. +# Cambiá ese comando a invocar el binario directo con "node", como el +# CMD de abajo — no vuelvas a agregar npm solo para eso. +RUN rm -rf /usr/local/lib/node_modules/npm + +WORKDIR /app + +# CAMBIAR: qué carpetas copiar del builder depende de tu build real +# (dist/, .output/, node_modules). Este ejemplo asume un build típico +# de tsc/esbuild a dist/ + node_modules de producción. +COPY --from=builder --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/dist ./dist + +# La imagen base node:*-bookworm-slim ya trae un usuario "node" (uid +# 1000) sin privilegios -- no hace falta crear uno nuevo, a diferencia +# de nextjs.Dockerfile. +USER node + +EXPOSE ${APP_PORT} + +HEALTHCHECK --interval=30s --timeout=8s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + +# CAMBIAR: el entrypoint real. Nunca "npm start"/"npx algo" -- ver nota +# de arriba sobre por qué npm no existe en esta imagen. +CMD ["node", "dist/index.js"] diff --git a/dockerfiles/static-nginx.Dockerfile b/dockerfiles/static-nginx.Dockerfile new file mode 100644 index 0000000..a3fdf7e --- /dev/null +++ b/dockerfiles/static-nginx.Dockerfile @@ -0,0 +1,66 @@ +# Basado en el Dockerfile real de docs-portal (sitio MkDocs Material). +# Mejora respecto al original: nginx corre como usuario NO-root acá +# (nginxinc/nginx-unprivileged) -- el docs-portal real del lab todavía +# corre nginx:1.27-alpine estándar (como root), un gap real encontrado +# al armar este template, pendiente de aplicar al repo real por +# separado. +# +# Este template asume MkDocs -- es el único generador de sitio estático +# que existe hoy en el lab. Si tu app usa otro generador (Hugo, Astro, +# un build de Vite sin backend), el STAGE 1 es lo único que cambia: +# reemplazalo por el build real de tu herramienta, el STAGE 2 +# (nginx no-root) sirve igual para cualquier sitio 100% estático. +# +# ARGs: +# PYTHON_IMAGE -- versión del builder (solo aplica al ejemplo MkDocs). +# NGINX_IMAGE -- versión de nginx no-root. +# APP_PORT -- OJO: esto solo cambia el EXPOSE/healthcheck de este +# Dockerfile, NO el puerto real en el que nginx +# escucha -- eso lo define el nginx.conf que trae la +# propia imagen nginx-unprivileged, fijo en 8080. Si +# cambiás este ARG a otro valor, tenés que agregar un +# nginx.conf propio (COPY) que escuche en ese mismo +# puerto, o quedan desalineados. Dejalo en 8080 +# mientras no necesites tocar nginx.conf. + +# syntax=docker/dockerfile:1.7 + +ARG PYTHON_IMAGE=python:3.12-slim +ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.27-alpine +ARG APP_PORT=8080 + +# --- Stage 1: build del sitio estático (ejemplo: MkDocs Material) --- +FROM ${PYTHON_IMAGE} AS build + +WORKDIR /site + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY mkdocs.yml . +COPY docs/ docs/ + +# --strict: cualquier link roto o warning de nav rompe el build, para +# no publicar nunca un sitio con referencias muertas. Si tu generador +# no tiene un equivalente a --strict, considerá agregar un paso de +# validación de links aparte (ej. linkinator) antes de este build. +RUN mkdocs build --strict + +# --- Stage 2: sirve el sitio estático con nginx, sin root --- +FROM ${NGINX_IMAGE} + +ARG APP_PORT +# ARG no alcanza para el HEALTHCHECK -- ese CMD corre en runtime, no en +# build time, y ahí un ARG ya no existe. Hace falta ENV. +ENV APP_PORT=${APP_PORT} + +# CAMBIAR "/site/site" al directorio real de salida de tu build si no +# usás MkDocs (ej. "dist/", "public/", "build/", ".output/public/"). +COPY --from=build /site/site /usr/share/nginx/html + +EXPOSE ${APP_PORT} + +# nginx-unprivileged trae wget (busybox) -- alcanza para un healthcheck +# simple sin instalar nada más. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null "http://127.0.0.1:${APP_PORT}/" || exit 1 diff --git a/k8s-manifests/base/deployment.yaml b/k8s-manifests/base/deployment.yaml new file mode 100644 index 0000000..4a82004 --- /dev/null +++ b/k8s-manifests/base/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app-deployment + labels: + app: app +spec: + replicas: 1 + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + imagePullSecrets: + - name: gitea-registry-secret + # Nivel Pod: nada corre como root a menos que el overlay lo + # sobreescriba explícitamente (y debería justificarlo si lo hace). + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: app + # CAMBIAR: imagen real de tu app. El overlay la sobreescribe. + image: gitea.cruzcloud.net/devops/CAMBIAR-imagen:v0.0.0 + ports: + - name: http + containerPort: 8080 + # Nivel contenedor: complementa el runAsNonRoot de arriba. + # readOnlyRootFilesystem queda en false por default porque + # muchos runtimes (Next.js standalone, cachés de npm/pip) + # escriben en disco al arrancar -- si tu app NO escribe nada + # en runtime, ponelo en true y listo (más seguro). + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + # CAMBIAR: casi seguro tu app expone un endpoint de salud + # distinto a "/" (ver README de tu arquetipo de pipeline — + # los 3 arquetipos ya documentan cuál usa cada stack). + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + # Límites conservadores para una app chica/media. Subilos en + # tu overlay si tu app realmente los necesita — pero no los + # borres: sin esto, un solo pod con un bug de memoria puede + # arrastrar al nodo entero a swap (visto en vivo con + # gitea-runner sin límites, ver docs-portal del lab). + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/k8s-manifests/base/ingress.yaml b/k8s-manifests/base/ingress.yaml new file mode 100644 index 0000000..9d08efb --- /dev/null +++ b/k8s-manifests/base/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app-ingress + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + # CAMBIAR: el host público real. Sigue el patrón *.cruzcloud.net + # ya usado por shop/commerce/docs — apunta a NPM -> serverlb -> + # Traefik -> este Service (ver reference_infra_topology en la + # memoria del lab si necesitás repasar la ruta completa). + - host: CAMBIAR-app.cruzcloud.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: app-svc + port: + number: 80 diff --git a/k8s-manifests/base/kustomization.yaml b/k8s-manifests/base/kustomization.yaml new file mode 100644 index 0000000..1d4f62e --- /dev/null +++ b/k8s-manifests/base/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/k8s-manifests/base/service.yaml b/k8s-manifests/base/service.yaml new file mode 100644 index 0000000..0376cb6 --- /dev/null +++ b/k8s-manifests/base/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: app-svc +spec: + selector: + app: app + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/kustomization.yaml b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/kustomization.yaml new file mode 100644 index 0000000..012c829 --- /dev/null +++ b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/kustomization.yaml @@ -0,0 +1,57 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mi-app-ejemplo + +resources: + - ../../base + +patches: + # Contenido real de tu app (imagen, puerto, probes, etc.) — el + # "name" del contenedor se queda en "app" a propósito, ver el + # comentario en patches/deployment.yaml. + - target: + kind: Deployment + name: app-deployment + path: patches/deployment.yaml + - target: + kind: Service + name: app-svc + path: patches/service.yaml + - target: + kind: Ingress + name: app-ingress + path: patches/ingress.yaml + + # Renombrar los objetos en sí (Kustomize no lo hace con un patch de + # merge normal -- cambiar metadata.name ahí simplemente no aplica. + # Hace falta un patch JSON6902 explícito, uno por recurso). + - target: + kind: Deployment + name: app-deployment + patch: |- + - op: replace + path: /metadata/name + value: mi-app-ejemplo-deployment + # El puerto real de la app: reemplazo la lista de "ports" entera + # por JSON6902 -- un patch de merge normal (ver patches/deployment.yaml) + # NO reemplaza esta lista, la duplica (confirmado en vivo). + - op: replace + path: /spec/template/spec/containers/0/ports + value: + - name: http + containerPort: 3000 + - target: + kind: Service + name: app-svc + patch: |- + - op: replace + path: /metadata/name + value: mi-app-ejemplo-svc + - target: + kind: Ingress + name: app-ingress + patch: |- + - op: replace + path: /metadata/name + value: mi-app-ejemplo-ingress diff --git a/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/deployment.yaml b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/deployment.yaml new file mode 100644 index 0000000..2a42dda --- /dev/null +++ b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/deployment.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app-deployment + labels: + app: mi-app-ejemplo +spec: + replicas: 1 + selector: + matchLabels: + app: mi-app-ejemplo + template: + metadata: + labels: + app: mi-app-ejemplo + spec: + containers: + # OJO: el "name" del contenedor se queda en "app", igual que en + # el base -- es la clave que usa Kustomize para fusionar este + # patch con el contenedor del base en vez de agregar uno + # nuevo al lado. No lo cambies. + - name: app + image: gitea.cruzcloud.net/devops/mi-app-ejemplo:v1.0.1 + # El puerto real NO se cambia acá -- $patch: replace no + # funciona para esto en esta versión de Kustomize (probado en + # vivo, se probó y se descartó). Se reemplaza con un patch + # JSON6902 aparte, ver kustomization.yaml. Si no tocás el + # puerto (8080 te sirve tal cual), podés borrar este comentario + # y el patch JSON6902 correspondiente, y listo. + readinessProbe: + httpGet: + path: /api/health + port: http + livenessProbe: + httpGet: + path: /api/health + port: http diff --git a/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/ingress.yaml b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/ingress.yaml new file mode 100644 index 0000000..96152f3 --- /dev/null +++ b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/ingress.yaml @@ -0,0 +1,16 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app-ingress +spec: + rules: + - host: mi-app-ejemplo.cruzcloud.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mi-app-ejemplo-svc + port: + number: 80 diff --git a/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/service.yaml b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/service.yaml new file mode 100644 index 0000000..6325e50 --- /dev/null +++ b/k8s-manifests/overlays-ejemplo/mi-app-ejemplo/patches/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: app-svc +spec: + selector: + app: mi-app-ejemplo + ports: + - name: http + port: 80 + targetPort: 3000 diff --git a/pipelines/_test-reusable.yaml b/pipelines/_test-reusable.yaml new file mode 100644 index 0000000..6d250a4 --- /dev/null +++ b/pipelines/_test-reusable.yaml @@ -0,0 +1,17 @@ +on: + workflow_call: + inputs: + greeting: + type: string + required: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: devops/platform-templates/devsecops-snippets/gitleaks-step@main + with: + source: devsecops-snippets + - shell: bash + run: echo "Reusable workflow input recibido -> '${{ inputs.greeting }}'" diff --git a/pipelines/archetype-nextjs-frontend/workflow-template.yaml b/pipelines/archetype-nextjs-frontend/workflow-template.yaml new file mode 100644 index 0000000..6ab5d47 --- /dev/null +++ b/pipelines/archetype-nextjs-frontend/workflow-template.yaml @@ -0,0 +1,193 @@ +# Arquetipo de pipeline: Next.js standalone. +# Extraído del pipeline real de ecommerce-frontend (apps-registry), +# generalizando lo específico de esa app (rutas hardcodeadas, checks +# de negocio del catálogo) en inputs. +# +# Cómo usarlo desde tu repo: ver pipelines/archetype-nextjs-frontend/README.md. + +on: + workflow_call: + inputs: + app_dir: + description: "Directorio de la app dentro de tu repo (ej. app/)." + type: string + required: true + image_name: + description: "Nombre completo de la imagen sin tag (ej. gitea.cruzcloud.net/devops/mi-app)." + type: string + required: true + manifest_file: + description: "Manifiesto K8s cuyo tag de imagen se actualiza tras el push (CD)." + type: string + required: true + semgrep_configs: + description: "Rulesets de Semgrep, separados por coma." + type: string + required: false + default: "p/typescript,p/react,p/nextjs,p/security-audit" + trivy_ignorefile: + description: "Ruta a un .trivyignore, si tu app tiene excepciones documentadas." + type: string + required: false + default: "" + secrets: + REGISTRY_USER: + required: true + REGISTRY_PASSWORD: + required: true + COSIGN_PRIVATE_KEY: + required: true + COSIGN_PASSWORD: + required: true + +jobs: + gitleaks: + name: Escaneo de Secretos (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + - uses: devops/platform-templates/devsecops-snippets/gitleaks-step@main + with: + source: ${{ inputs.app_dir }} + + build: + name: Construir y Subir Imagen + needs: gitleaks + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + APP_DIR: ${{ inputs.app_dir }} + MANIFEST_FILE: ${{ inputs.manifest_file }} + IMAGE_NAME: ${{ inputs.image_name }} + + steps: + - name: Checkout del código + uses: actions/checkout@v3 + with: + fetch-depth: 1 + persist-credentials: true + + - name: Definir versión semántica + id: vars + shell: bash + run: | + set -euo pipefail + echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Validar secretos del Registry + shell: bash + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + test -n "${REGISTRY_USER}" || { echo "ERROR: REGISTRY_USER no está configurado."; exit 1; } + test -n "${REGISTRY_PASSWORD}" || { echo "ERROR: REGISTRY_PASSWORD no está configurado."; exit 1; } + + - uses: devops/platform-templates/devsecops-snippets/trivy-iac-step@main + with: + target: ${{ env.MANIFEST_FILE }} + + - uses: devops/platform-templates/devsecops-snippets/semgrep-step@main + with: + target: ${{ env.APP_DIR }} + configs: ${{ inputs.semgrep_configs }} + + - name: Login en Gitea Registry + uses: docker/login-action@v2 + with: + registry: gitea.cruzcloud.net + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + logout: true + + # push: false — la imagen se queda cargada localmente para poder + # escanearla con Trivy antes de subirla. + - name: Construir Imagen + uses: docker/build-push-action@v4 + with: + context: ${{ env.APP_DIR }} + push: false + load: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ${{ env.IMAGE_NAME }}:latest + + - uses: devops/platform-templates/devsecops-snippets/trivy-image-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ignorefile: ${{ inputs.trivy_ignorefile }} + + - uses: devops/platform-templates/devsecops-snippets/syft-sbom-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + artifact_suffix: ${{ steps.vars.outputs.VERSION }} + + - name: Subir Imagen al Registry + id: push + shell: bash + run: | + set -euo pipefail + PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")" + echo "${PUSH_OUTPUT}" + docker push "${IMAGE_NAME}:latest" + DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)" + test -n "${DIGEST}" || { echo "ERROR: no se pudo extraer el digest del push."; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + + - uses: devops/platform-templates/devsecops-snippets/cosign-sign-verify-step@main + with: + image_name: ${{ env.IMAGE_NAME }} + digest: ${{ steps.push.outputs.digest }} + public_key_path: ${{ env.APP_DIR }}/cosign.pub + cosign_private_key: ${{ secrets.COSIGN_PRIVATE_KEY }} + cosign_password: ${{ secrets.COSIGN_PASSWORD }} + + - name: Verificar promoción segura + id: promotion + shell: bash + run: | + set -euo pipefail + git fetch origin main + CURRENT_SHA="${{ github.sha }}" + REMOTE_SHA="$(git rev-parse origin/main)" + if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then + echo "promote=true" >> "$GITHUB_OUTPUT" + else + echo "promote=false" >> "$GITHUB_OUTPUT" + echo "Hay un commit más reciente; no se actualizará el manifiesto." + fi + + - name: Actualizar Manifiesto GitOps (CD) + if: steps.promotion.outputs.promote == 'true' + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.VERSION }}" + git config user.name "gitea-actions" + git config user.email "gitea-actions@cruzcloud.net" + git fetch origin main + git checkout -B main origin/main + sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}" + git add "${MANIFEST_FILE}" + if git diff --cached --quiet; then + echo "No hay cambios en el manifiesto." + exit 0 + fi + git commit -m "chore(gitops): deploy ${VERSION} [skip ci]" + git push origin HEAD:main + + - name: Resumen del pipeline + if: always() + shell: bash + run: | + echo "========================================" + echo "Versión: ${{ steps.vars.outputs.VERSION }}" + echo "Commit: ${{ github.sha }}" + echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}" + echo "========================================" diff --git a/pipelines/archetype-node-api/workflow-template.yaml b/pipelines/archetype-node-api/workflow-template.yaml new file mode 100644 index 0000000..7ba3cfb --- /dev/null +++ b/pipelines/archetype-node-api/workflow-template.yaml @@ -0,0 +1,193 @@ +# Arquetipo de pipeline: API Node/Express (o similar, sin SSR). +# Extraído del pipeline real de commerce-backend/Medusa (apps-registry). +# +# Cómo usarlo desde tu repo: ver pipelines/archetype-node-api/README.md. + +on: + workflow_call: + inputs: + app_dir: + description: "Directorio de la app dentro de tu repo." + type: string + required: true + image_name: + description: "Nombre completo de la imagen sin tag." + type: string + required: true + manifest_file: + description: "Manifiesto K8s cuyo tag de imagen se actualiza tras el push (CD)." + type: string + required: true + semgrep_configs: + description: "Rulesets de Semgrep, separados por coma." + type: string + required: false + default: "p/typescript,p/security-audit,p/owasp-top-ten" + trivy_ignorefile: + description: "Ruta a un .trivyignore, si tu app tiene excepciones documentadas." + type: string + required: false + default: "" + secrets: + REGISTRY_USER: + required: true + REGISTRY_PASSWORD: + required: true + COSIGN_PRIVATE_KEY: + required: true + COSIGN_PASSWORD: + required: true + +jobs: + gitleaks: + name: Escaneo de Secretos (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + - uses: devops/platform-templates/devsecops-snippets/gitleaks-step@main + with: + source: ${{ inputs.app_dir }} + + build: + name: Construir y Subir Imagen + needs: gitleaks + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 45 + + env: + APP_DIR: ${{ inputs.app_dir }} + MANIFEST_FILE: ${{ inputs.manifest_file }} + IMAGE_NAME: ${{ inputs.image_name }} + + steps: + - name: Checkout del código + uses: actions/checkout@v3 + with: + fetch-depth: 1 + persist-credentials: true + + - name: Definir versión + id: vars + shell: bash + run: | + set -euo pipefail + echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Validar secretos del Registry + shell: bash + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + test -n "${REGISTRY_USER}" || { echo "ERROR: REGISTRY_USER no está configurado."; exit 1; } + test -n "${REGISTRY_PASSWORD}" || { echo "ERROR: REGISTRY_PASSWORD no está configurado."; exit 1; } + + - uses: devops/platform-templates/devsecops-snippets/trivy-iac-step@main + with: + target: ${{ env.MANIFEST_FILE }} + + - uses: devops/platform-templates/devsecops-snippets/semgrep-step@main + with: + target: ${{ env.APP_DIR }} + configs: ${{ inputs.semgrep_configs }} + + - name: Login en Gitea Registry + uses: docker/login-action@v2 + with: + registry: gitea.cruzcloud.net + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + logout: true + + - name: Construir Imagen + uses: docker/build-push-action@v4 + with: + context: ${{ env.APP_DIR }} + push: false + load: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ${{ env.IMAGE_NAME }}:latest + + # --timeout 15m0s ya viene por default en trivy-image-step — + # commerce-backend (Medusa v2) es exactamente el caso real que + # justificó ese default (node_modules completo del framework + + # admin-sdk + cli, mucho más que escanear que una API chica). + - uses: devops/platform-templates/devsecops-snippets/trivy-image-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ignorefile: ${{ inputs.trivy_ignorefile }} + + - uses: devops/platform-templates/devsecops-snippets/syft-sbom-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + artifact_suffix: ${{ steps.vars.outputs.VERSION }} + + - name: Subir Imagen al Registry + id: push + shell: bash + run: | + set -euo pipefail + PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")" + echo "${PUSH_OUTPUT}" + docker push "${IMAGE_NAME}:latest" + DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)" + test -n "${DIGEST}" || { echo "ERROR: no se pudo extraer el digest del push."; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + + - uses: devops/platform-templates/devsecops-snippets/cosign-sign-verify-step@main + with: + image_name: ${{ env.IMAGE_NAME }} + digest: ${{ steps.push.outputs.digest }} + public_key_path: ${{ env.APP_DIR }}/cosign.pub + cosign_private_key: ${{ secrets.COSIGN_PRIVATE_KEY }} + cosign_password: ${{ secrets.COSIGN_PASSWORD }} + + - name: Verificar promoción segura + id: promotion + shell: bash + run: | + set -euo pipefail + git fetch origin main + CURRENT_SHA="${{ github.sha }}" + REMOTE_SHA="$(git rev-parse origin/main)" + if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then + echo "promote=true" >> "$GITHUB_OUTPUT" + else + echo "promote=false" >> "$GITHUB_OUTPUT" + echo "Hay un commit más reciente; no se actualizará el manifiesto." + fi + + - name: Actualizar manifiesto + if: steps.promotion.outputs.promote == 'true' + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.VERSION }}" + git config user.name "gitea-actions" + git config user.email "gitea-actions@cruzcloud.net" + git fetch origin main + git checkout -B main origin/main + sed -i -E "s|(${IMAGE_NAME}:)v[0-9]+\.[0-9]+\.[0-9]+|\1${VERSION}|g" "${MANIFEST_FILE}" + git add "${MANIFEST_FILE}" + if git diff --cached --quiet; then + echo "El manifiesto ya apunta a ${VERSION}." + exit 0 + fi + git commit -m "chore(gitops): deploy ${VERSION} [skip ci]" + git push origin HEAD:main + + - name: Resumen del pipeline + if: always() + shell: bash + run: | + echo "========================================" + echo "Versión: ${{ steps.vars.outputs.VERSION }}" + echo "Commit: ${{ github.sha }}" + echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}" + echo "========================================" diff --git a/pipelines/archetype-static-site/workflow-template.yaml b/pipelines/archetype-static-site/workflow-template.yaml new file mode 100644 index 0000000..e21d592 --- /dev/null +++ b/pipelines/archetype-static-site/workflow-template.yaml @@ -0,0 +1,195 @@ +# Arquetipo de pipeline: sitio estático servido por nginx (MkDocs, +# Hugo, Astro, HTML plano). Extraído del pipeline real de docs-portal +# (apps-registry). +# +# Diferencia clave con los otros 2 arquetipos: gitleaks y Trivy IaC +# escanean todo APP_DIR (no un único manifest_file) — un sitio estático +# suele tener varios manifiestos K8s separados (deployment/service/ +# ingress) en vez de uno solo. +# +# Cómo usarlo desde tu repo: ver pipelines/archetype-static-site/README.md. + +on: + workflow_call: + inputs: + app_dir: + description: "Directorio de la app dentro de tu repo." + type: string + required: true + image_name: + description: "Nombre completo de la imagen sin tag." + type: string + required: true + manifest_file: + description: "Manifiesto K8s (Deployment) cuyo tag de imagen se actualiza tras el push (CD)." + type: string + required: true + semgrep_configs: + description: "Rulesets de Semgrep, separados por coma." + type: string + required: false + default: "p/python,p/security-audit" + trivy_ignorefile: + description: "Ruta a un .trivyignore, si tu app tiene excepciones documentadas." + type: string + required: false + default: "" + secrets: + REGISTRY_USER: + required: true + REGISTRY_PASSWORD: + required: true + COSIGN_PRIVATE_KEY: + required: true + COSIGN_PASSWORD: + required: true + +jobs: + gitleaks: + name: Escaneo de Secretos (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + - uses: devops/platform-templates/devsecops-snippets/gitleaks-step@main + with: + source: ${{ inputs.app_dir }} + + build: + name: Construir y publicar + needs: gitleaks + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + APP_DIR: ${{ inputs.app_dir }} + MANIFEST_FILE: ${{ inputs.manifest_file }} + IMAGE_NAME: ${{ inputs.image_name }} + + steps: + - name: Checkout del código + uses: actions/checkout@v3 + with: + fetch-depth: 1 + persist-credentials: true + + - name: Definir versión + id: vars + shell: bash + run: | + set -euo pipefail + echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Validar secretos del Registry + shell: bash + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + test -n "${REGISTRY_USER}" || { echo "ERROR: REGISTRY_USER no está configurado."; exit 1; } + test -n "${REGISTRY_PASSWORD}" || { echo "ERROR: REGISTRY_PASSWORD no está configurado."; exit 1; } + + - uses: devops/platform-templates/devsecops-snippets/trivy-iac-step@main + with: + target: ${{ env.APP_DIR }} + + - uses: devops/platform-templates/devsecops-snippets/semgrep-step@main + with: + target: ${{ env.APP_DIR }} + configs: ${{ inputs.semgrep_configs }} + + - name: Login en Gitea Registry + uses: docker/login-action@v2 + with: + registry: gitea.cruzcloud.net + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + logout: true + + - name: Construir Imagen + uses: docker/build-push-action@v4 + with: + context: ${{ env.APP_DIR }} + push: false + load: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ${{ env.IMAGE_NAME }}:latest + + - uses: devops/platform-templates/devsecops-snippets/trivy-image-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + ignorefile: ${{ inputs.trivy_ignorefile }} + + - uses: devops/platform-templates/devsecops-snippets/syft-sbom-step@main + with: + image_ref: ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }} + artifact_suffix: ${{ steps.vars.outputs.VERSION }} + + - name: Subir Imagen al Registry + id: push + shell: bash + run: | + set -euo pipefail + PUSH_OUTPUT="$(docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}")" + echo "${PUSH_OUTPUT}" + docker push "${IMAGE_NAME}:latest" + DIGEST="$(echo "${PUSH_OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)" + test -n "${DIGEST}" || { echo "ERROR: no se pudo extraer el digest del push."; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + + - uses: devops/platform-templates/devsecops-snippets/cosign-sign-verify-step@main + with: + image_name: ${{ env.IMAGE_NAME }} + digest: ${{ steps.push.outputs.digest }} + public_key_path: ${{ env.APP_DIR }}/cosign.pub + cosign_private_key: ${{ secrets.COSIGN_PRIVATE_KEY }} + cosign_password: ${{ secrets.COSIGN_PASSWORD }} + + - name: Verificar promoción segura + id: promotion + shell: bash + run: | + set -euo pipefail + git fetch origin main + CURRENT_SHA="${{ github.sha }}" + REMOTE_SHA="$(git rev-parse origin/main)" + if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then + echo "promote=true" >> "$GITHUB_OUTPUT" + else + echo "promote=false" >> "$GITHUB_OUTPUT" + echo "Hay un commit más reciente; no se actualizará el manifiesto." + fi + + - name: Actualizar manifiesto GitOps + if: steps.promotion.outputs.promote == 'true' + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.VERSION }}" + git config user.name "gitea-actions" + git config user.email "gitea-actions@cruzcloud.net" + git fetch origin main + git checkout -B main origin/main + sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}" + git add "${MANIFEST_FILE}" + if git diff --cached --quiet; then + echo "El manifiesto ya apunta a ${VERSION}." + exit 0 + fi + git commit -m "chore(gitops): deploy ${VERSION} [skip ci]" + git push origin HEAD:main + + - name: Resumen del pipeline + if: always() + shell: bash + run: | + echo "========================================" + echo "Versión: ${{ steps.vars.outputs.VERSION }}" + echo "Commit: ${{ github.sha }}" + echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}" + echo "========================================"