name: Build, Push and Validate Frontend on: push: branches: - main paths: - 'workloads/ecommerce/index.html' - 'workloads/ecommerce/Dockerfile' - '.gitea/workflows/build.yaml' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout del código uses: actions/checkout@v3 with: fetch-depth: 0 - name: Definir Versión Semántica id: vars shell: bash run: | set -euo pipefail VERSION="v1.0.${{ gitea.run_number }}" echo "VERSION=${VERSION}" >> "${GITHUB_OUTPUT}" echo "Versión generada: ${VERSION}" - name: Login en Gitea Registry uses: docker/login-action@v2 with: registry: gitea.cruzcloud.net username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Construir y Subir Imagen uses: docker/build-push-action@v4 with: context: workloads/ecommerce/ push: true tags: | gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }} gitea.cruzcloud.net/devops/ecommerce-frontend:latest - name: Actualizar Manifiesto GitOps (CD) shell: bash env: VERSION: ${{ steps.vars.outputs.VERSION }} MANIFEST: workloads/ecommerce/frontend.yaml IMAGE_REPOSITORY: gitea.cruzcloud.net/devops/ecommerce-frontend run: | set -euo pipefail git config user.name "Cristian Felipe" git config user.email "cristiancruz0529@gmail.com" for intento in 1 2 3; do echo "--------------------------------------------------" echo "Intento ${intento}/3" echo "Versión: ${VERSION}" echo "Manifiesto: ${MANIFEST}" echo "--------------------------------------------------" git fetch origin main git checkout -B main origin/main if [ ! -f "${MANIFEST}" ]; then echo "ERROR: no existe ${MANIFEST}." exit 1 fi if ! grep -qE "image:[[:space:]]*${IMAGE_REPOSITORY}:" "${MANIFEST}"; then echo "ERROR: no se encontró ${IMAGE_REPOSITORY} en ${MANIFEST}." grep -nE '^[[:space:]]*image:' "${MANIFEST}" || true exit 1 fi sed -i -E \ "s|(image:[[:space:]]*${IMAGE_REPOSITORY}:).*|\\1${VERSION}|g" \ "${MANIFEST}" echo "Imagen resultante:" grep -nE '^[[:space:]]*image:' "${MANIFEST}" || true git add "${MANIFEST}" if git diff --cached --quiet; then echo "El manifiesto ya contiene ${VERSION}." break fi git commit -m "chore: release ${VERSION} [skip ci]" if git push origin HEAD:main; then echo "Manifiesto actualizado correctamente con ${VERSION}." break fi if [ "${intento}" -eq 3 ]; then echo "ERROR: no fue posible actualizar main después de 3 intentos." exit 1 fi espera=$((intento * 3)) echo "origin/main cambió. Reintentando en ${espera} segundos..." sleep "${espera}" done - name: Validar despliegue y tag ejecutado en Kubernetes shell: bash env: VERSION: ${{ steps.vars.outputs.VERSION }} IMAGE_REPOSITORY: gitea.cruzcloud.net/devops/ecommerce-frontend NAMESPACE: ecommerce DEPLOY_TIMEOUT_SECONDS: '420' KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} K8S_SERVER: ${{ secrets.K8S_SERVER }} K8S_CA_B64: ${{ secrets.K8S_CA_B64 }} K8S_TOKEN: ${{ secrets.K8S_TOKEN }} K8S_TLS_SERVER_NAME: ${{ secrets.K8S_TLS_SERVER_NAME }} run: | set -euo pipefail EXPECTED_IMAGE="${IMAGE_REPOSITORY}:${VERSION}" KUBECONFIG_PATH="${RUNNER_TEMP:-/tmp}/kubeconfig" CA_PATH="${RUNNER_TEMP:-/tmp}/kubernetes-ca.crt" echo "==================================================" echo "Validación posterior al despliegue" echo "Namespace: ${NAMESPACE}" echo "Imagen esperada: ${EXPECTED_IMAGE}" echo "Timeout: ${DEPLOY_TIMEOUT_SECONDS} segundos" echo "==================================================" normalize_base64() { local value="$1" value="$(printf '%s' "${value}" | tr -d '\r\n\t ')" if ! printf '%s' "${value}" | grep -Eq '^[A-Za-z0-9+/]*={0,2}$'; then return 1 fi case $(( ${#value} % 4 )) in 0) ;; 2) value="${value}==" ;; 3) value="${value}=" ;; 1) return 1 ;; esac printf '%s' "${value}" } USE_ATOMIC_KUBECONFIG='false' KUBE_CONFIG_DATA="$(printf '%s' "${KUBE_CONFIG_DATA:-}" | tr -d '\r\n\t ')" if [ -n "${KUBE_CONFIG_DATA}" ]; then echo 'Usando secret atómico KUBE_CONFIG_DATA.' if ! KUBE_CONFIG_DATA="$(normalize_base64 "${KUBE_CONFIG_DATA}")"; then echo 'ERROR: KUBE_CONFIG_DATA no contiene Base64 válido.' exit 1 fi if ! printf '%s' "${KUBE_CONFIG_DATA}" | base64 --decode > "${KUBECONFIG_PATH}" 2>/dev/null; then echo 'ERROR: no se pudo decodificar KUBE_CONFIG_DATA.' exit 1 fi if ! grep -q '^kind:[[:space:]]*Config' "${KUBECONFIG_PATH}"; then echo 'ERROR: KUBE_CONFIG_DATA no contiene un kubeconfig válido.' exit 1 fi USE_ATOMIC_KUBECONFIG='true' else echo 'KUBE_CONFIG_DATA no está configurado; usando secrets K8S_* de compatibilidad.' for variable in K8S_SERVER K8S_CA_B64 K8S_TOKEN; do if [ -z "${!variable:-}" ]; then echo "ERROR: no está configurado el secret ${variable}." exit 1 fi done K8S_SERVER="$(printf '%s' "${K8S_SERVER}" | tr -d '\r\n\t ')" K8S_TOKEN="$(printf '%s' "${K8S_TOKEN}" | tr -d '\r\n')" K8S_CA_B64="$(printf '%s' "${K8S_CA_B64}" | tr -d '\r\n\t ')" K8S_TLS_SERVER_NAME="$(printf '%s' "${K8S_TLS_SERVER_NAME:-}" | tr -d '\r\n\t ')" if ! printf '%s' "${K8S_SERVER}" | grep -Eq '^https://[^[:space:]]+:[0-9]+$'; then echo 'ERROR: K8S_SERVER no tiene un formato válido.' echo 'Ejemplo: https://192.168.68.61:46421' exit 1 fi if printf '%s' "${K8S_SERVER}" | grep -Eq '^https://(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|/)'; then echo 'ERROR: K8S_SERVER usa una dirección no accesible desde el contenedor.' exit 1 fi K8S_SERVER_HOST="$( printf '%s' "${K8S_SERVER}" | sed -E 's#^https://\[?([^]]+)\]?:[0-9]+$#\1#' )" if [ -z "${K8S_TLS_SERVER_NAME}" ]; then K8S_TLS_SERVER_NAME="${K8S_SERVER_HOST}" fi if [ "${K8S_TLS_SERVER_NAME}" != "${K8S_SERVER_HOST}" ]; then echo 'ERROR: K8S_TLS_SERVER_NAME no coincide con el host de K8S_SERVER.' echo "Host de K8S_SERVER: ${K8S_SERVER_HOST}" echo "Valor esperado del secret K8S_TLS_SERVER_NAME: ${K8S_SERVER_HOST}" exit 1 fi if ! K8S_CA_B64="$(normalize_base64 "${K8S_CA_B64}")"; then echo 'ERROR: K8S_CA_B64 no contiene Base64 válido.' exit 1 fi if ! printf '%s' "${K8S_CA_B64}" | base64 --decode > "${CA_PATH}" 2>/dev/null; then echo 'ERROR: no se pudo decodificar K8S_CA_B64.' exit 1 fi if ! grep -q 'BEGIN CERTIFICATE' "${CA_PATH}"; then echo 'ERROR: K8S_CA_B64 no contiene un certificado PEM.' exit 1 fi chmod 600 "${CA_PATH}" { echo 'apiVersion: v1' echo 'kind: Config' echo 'clusters:' echo '- name: lab-cluster' echo ' cluster:' echo " server: ${K8S_SERVER}" echo " certificate-authority: ${CA_PATH}" echo " tls-server-name: ${K8S_TLS_SERVER_NAME}" echo 'users:' echo '- name: gitea-postdeploy-validator' echo ' user:' echo " token: ${K8S_TOKEN}" echo 'contexts:' echo '- name: gitea-postdeploy-validator' echo ' context:' echo ' cluster: lab-cluster' echo ' user: gitea-postdeploy-validator' echo " namespace: ${NAMESPACE}" echo 'current-context: gitea-postdeploy-validator' } > "${KUBECONFIG_PATH}" fi chmod 600 "${KUBECONFIG_PATH}" export KUBECONFIG="${KUBECONFIG_PATH}" if ! command -v kubectl >/dev/null 2>&1; then case "$(uname -m)" in x86_64) KUBECTL_ARCH='amd64' ;; aarch64|arm64) KUBECTL_ARCH='arm64' ;; *) echo "ERROR: arquitectura no soportada: $(uname -m)" exit 1 ;; esac KUBECTL_VERSION="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" echo "Instalando kubectl ${KUBECTL_VERSION} (${KUBECTL_ARCH})..." curl -fsSLo /tmp/kubectl \ "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl" curl -fsSLo /tmp/kubectl.sha256 \ "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl.sha256" echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum --check chmod +x /tmp/kubectl export PATH="/tmp:${PATH}" fi kubectl version --client EFFECTIVE_SERVER="$( kubectl config view --raw --minify \ -o jsonpath='{.clusters[0].cluster.server}' )" EFFECTIVE_TLS_NAME="$( kubectl config view --raw --minify \ -o jsonpath='{.clusters[0].cluster.tls-server-name}' )" EFFECTIVE_CA_B64="$( kubectl config view --raw --minify \ -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' )" EFFECTIVE_TOKEN="$( kubectl config view --raw --minify \ -o jsonpath='{.users[0].user.token}' )" if [ -z "${EFFECTIVE_TOKEN}" ]; then echo 'ERROR: el kubeconfig no contiene un token de autenticación.' exit 1 fi echo "API Server efectivo: ${EFFECTIVE_SERVER}" echo "TLS server name efectivo: ${EFFECTIVE_TLS_NAME:-}" if [ -n "${EFFECTIVE_CA_B64}" ]; then printf '%s' "${EFFECTIVE_CA_B64}" | base64 --decode > "${CA_PATH}" fi if [ ! -s "${CA_PATH}" ] || ! grep -q 'BEGIN CERTIFICATE' "${CA_PATH}"; then echo 'ERROR: el kubeconfig no contiene una CA PEM válida.' exit 1 fi if command -v openssl >/dev/null 2>&1; then echo 'Huella SHA256 de la CA usada por el workflow:' openssl x509 -in "${CA_PATH}" -noout -fingerprint -sha256 fi TOKEN_METADATA="$( printf '%s' "${EFFECTIVE_TOKEN}" | python3 -c ' import base64 import datetime import json import sys token = sys.stdin.read().strip() parts = token.split(".") if len(parts) != 3: print("formato=no-jwt") raise SystemExit(0) payload = parts[1] + "=" * (-len(parts[1]) % 4) data = json.loads(base64.urlsafe_b64decode(payload.encode())) subject = data.get("sub", "desconocido") expiration = data.get("exp") if expiration is None: print(f"subject={subject}; expiracion=sin-exp") else: when = datetime.datetime.fromtimestamp( expiration, datetime.timezone.utc ).isoformat() print(f"subject={subject}; expiracion={when}") ' )" echo "Identidad del token: ${TOKEN_METADATA}" if ! kubectl get deployments \ --namespace "${NAMESPACE}" \ --request-timeout='15s' >/dev/null; then echo "ERROR: no hay conectividad o permisos para leer Deployments en ${NAMESPACE}." exit 1 fi echo "Conectividad y permisos de lectura confirmados." DEADLINE=$((SECONDS + DEPLOY_TIMEOUT_SECONDS)) DEPLOYMENT='' while [ "${SECONDS}" -lt "${DEADLINE}" ]; do mapfile -t MATCHES < <( kubectl get deployments \ --namespace "${NAMESPACE}" \ --output go-template="{{range .items}}{{\$name := .metadata.name}}{{range .spec.template.spec.containers}}{{if eq .image \"${EXPECTED_IMAGE}\"}}{{\$name}}{{\"\\n\"}}{{end}}{{end}}{{end}}" | sort -u ) if [ "${#MATCHES[@]}" -eq 1 ] && [ -n "${MATCHES[0]}" ]; then DEPLOYMENT="${MATCHES[0]}" echo "Deployment actualizado por Argo CD: ${DEPLOYMENT}" break fi if [ "${#MATCHES[@]}" -gt 1 ]; then echo "ERROR: más de un Deployment usa ${EXPECTED_IMAGE}: ${MATCHES[*]}" exit 1 fi echo "Argo CD aún no ha aplicado ${EXPECTED_IMAGE}. Reintentando en 10 segundos..." sleep 10 done if [ -z "${DEPLOYMENT}" ]; then echo "ERROR: Argo CD no aplicó ${EXPECTED_IMAGE} dentro del tiempo límite." kubectl get deployments \ --namespace "${NAMESPACE}" \ --output custom-columns='DEPLOYMENT:.metadata.name,IMAGES:.spec.template.spec.containers[*].image' exit 1 fi kubectl rollout status "deployment/${DEPLOYMENT}" \ --namespace "${NAMESPACE}" \ --timeout="${DEPLOY_TIMEOUT_SECONDS}s" CONTAINER_NAME="$( kubectl get deployment "${DEPLOYMENT}" \ --namespace "${NAMESPACE}" \ --output go-template="{{range .spec.template.spec.containers}}{{if eq .image \"${EXPECTED_IMAGE}\"}}{{.name}}{{end}}{{end}}" )" if [ -z "${CONTAINER_NAME}" ]; then echo "ERROR: no se encontró el contenedor con ${EXPECTED_IMAGE}." exit 1 fi SELECTOR="$( kubectl get deployment "${DEPLOYMENT}" \ --namespace "${NAMESPACE}" \ --output go-template='{{range $key, $value := .spec.selector.matchLabels}}{{printf "%s=%s," $key $value}}{{end}}' | sed 's/,$//' )" EXPECTED_REPLICAS="$( kubectl get deployment "${DEPLOYMENT}" \ --namespace "${NAMESPACE}" \ --output jsonpath='{.spec.replicas}' )" mapfile -t PODS < <( kubectl get pods \ --namespace "${NAMESPACE}" \ --selector "${SELECTOR}" \ --output go-template='{{range .items}}{{if not .metadata.deletionTimestamp}}{{if eq .status.phase "Running"}}{{.metadata.name}}{{"\n"}}{{end}}{{end}}{{end}}' ) if [ "${#PODS[@]}" -ne "${EXPECTED_REPLICAS}" ]; then echo "ERROR: se esperaban ${EXPECTED_REPLICAS} Pods Running y se encontraron ${#PODS[@]}." kubectl get pods \ --namespace "${NAMESPACE}" \ --selector "${SELECTOR}" \ -o wide exit 1 fi VALIDATED_PODS=0 for pod in "${PODS[@]}"; do DECLARED_IMAGE="$( kubectl get pod "${pod}" \ --namespace "${NAMESPACE}" \ --output go-template="{{range .spec.containers}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.image}}{{end}}{{end}}" )" READY="$( kubectl get pod "${pod}" \ --namespace "${NAMESPACE}" \ --output go-template="{{range .status.containerStatuses}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.ready}}{{end}}{{end}}" )" IMAGE_ID="$( kubectl get pod "${pod}" \ --namespace "${NAMESPACE}" \ --output go-template="{{range .status.containerStatuses}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.imageID}}{{end}}{{end}}" )" echo "Pod: ${pod}" echo " Ready: ${READY}" echo " Imagen declarada: ${DECLARED_IMAGE}" echo " Image ID: ${IMAGE_ID}" if [ "${DECLARED_IMAGE}" != "${EXPECTED_IMAGE}" ]; then echo "ERROR: ${pod} ejecuta ${DECLARED_IMAGE}, no ${EXPECTED_IMAGE}." exit 1 fi if [ "${READY}" != 'true' ]; then echo "ERROR: el contenedor ${CONTAINER_NAME} de ${pod} no está Ready." exit 1 fi VALIDATED_PODS=$((VALIDATED_PODS + 1)) done echo "==================================================" echo "VALIDACIÓN EXITOSA" echo "Deployment: ${DEPLOYMENT}" echo "Pods validados: ${VALIDATED_PODS}/${EXPECTED_REPLICAS}" echo "Tag ejecutado: ${EXPECTED_IMAGE}" echo "=================================================="