wip: contenido inicial + test de reusable workflow / composite action
This commit is contained in:
1 parent
e9fe7d0567
commit
262c0f271a
22 files changed
+1480
No files matched your search
@@ -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"
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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 }}"
|
||||
@@ -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"]
|
||||
@@ -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 <algo>" 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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: app-svc
|
||||
spec:
|
||||
selector:
|
||||
app: app
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}'"
|
||||
@@ -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 "[email protected]"
|
||||
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 "========================================"
|
||||
@@ -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 "[email protected]"
|
||||
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 "========================================"
|
||||
@@ -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 "[email protected]"
|
||||
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 "========================================"
|
||||
Reference in new issue
Block a user