name: Build, Push and Validate Frontend on: push: branches: - main paths: - 'workloads/ecommerce/index.html' - 'workloads/ecommerce/Dockerfile' jobs: build: runs-on: ubuntu-latest timeout-minutes: 25 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.${{ github.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" # El repositorio remoto puede cambiar mientras se construye la imagen. # Se reintenta desde el último origin/main sin usar push --force. 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 el archivo ${MANIFEST}." exit 1 fi if ! grep -qE "image:[[:space:]]*${IMAGE_REPOSITORY}:" "${MANIFEST}"; then echo "ERROR: no se encontró la imagen ${IMAGE_REPOSITORY} en ${MANIFEST}." echo "Líneas image encontradas:" grep -nE "^[[:space:]]*image:" "${MANIFEST}" || true exit 1 fi sed -i -E \ "s|(image:[[:space:]]*${IMAGE_REPOSITORY}:).*|\\1${VERSION}|g" \ "${MANIFEST}" echo "Imagen resultante en el manifiesto:" grep -nE "^[[:space:]]*image:" "${MANIFEST}" || true git add "${MANIFEST}" if git diff --cached --quiet; then echo "El manifiesto ya contiene la versión ${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 echo "origin/main cambió mientras se realizaba el push." if [ "${intento}" -eq 3 ]; then echo "ERROR: no fue posible actualizar main después de 3 intentos." exit 1 fi espera=$((intento * 3)) echo "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 }} run: | set -euo pipefail EXPECTED_IMAGE="${IMAGE_REPOSITORY}:${VERSION}" echo "==================================================" echo "Validación posterior al despliegue" echo "Namespace: ${NAMESPACE}" echo "Imagen esperada: ${EXPECTED_IMAGE}" echo "Timeout: ${DEPLOY_TIMEOUT_SECONDS} segundos" echo "==================================================" if [ -z "${KUBE_CONFIG_DATA:-}" ]; then echo "ERROR: no está configurado el secret KUBE_CONFIG_DATA." exit 1 fi mkdir -p "${HOME}/.kube" # Normaliza saltos de línea, CRLF, espacios o tabulaciones que # hayan podido introducirse al copiar el secret en la interfaz. KUBE_CONFIG_B64="$( printf '%s' "${KUBE_CONFIG_DATA}" \ | tr -d '\r\n\t ' )" if [ -z "${KUBE_CONFIG_B64}" ]; then echo "ERROR: KUBE_CONFIG_DATA quedó vacío después de normalizarlo." exit 1 fi if ! printf '%s' "${KUBE_CONFIG_B64}" \ | grep -Eq '^[A-Za-z0-9+/]*={0,2}$'; then echo "ERROR: KUBE_CONFIG_DATA contiene caracteres que no pertenecen a Base64." echo "Vuelve a generar el secret codificando el archivo kubeconfig completo." exit 1 fi if [ $(( ${#KUBE_CONFIG_B64} % 4 )) -ne 0 ]; then echo "ERROR: la longitud de KUBE_CONFIG_DATA no es válida para Base64." echo "Probablemente el valor fue copiado de forma incompleta." exit 1 fi if ! printf '%s' "${KUBE_CONFIG_B64}" \ | base64 --decode > "${HOME}/.kube/config"; then echo "ERROR: no fue posible decodificar KUBE_CONFIG_DATA." exit 1 fi chmod 600 "${HOME}/.kube/config" export KUBECONFIG="${HOME}/.kube/config" if ! grep -q '^apiVersion:' "${KUBECONFIG}" \ || ! grep -q '^clusters:' "${KUBECONFIG}" \ || ! grep -q '^contexts:' "${KUBECONFIG}"; then echo "ERROR: el contenido decodificado no parece ser un kubeconfig válido." echo "No pegues el YAML sin codificar ni una cadena ya recortada." exit 1 fi # Instala kubectl únicamente si el runner no lo incluye. 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 para kubectl: $(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 # También valida conectividad y permisos mínimos sobre el namespace. kubectl get deployments --namespace "${NAMESPACE}" >/dev/null # Argo CD sincroniza de forma asíncrona. Se espera hasta que algún # Deployment del namespace tenga exactamente la imagen publicada. DEADLINE=$((SECONDS + DEPLOY_TIMEOUT_SECONDS)) DEPLOYMENT="" while [ "${SECONDS}" -lt "${DEADLINE}" ]; do MATCHES=() while IFS= read -r candidate; do [ -n "${candidate}" ] || continue candidate_images="$( kubectl get deployment "${candidate}" \ --namespace "${NAMESPACE}" \ --output jsonpath='{.spec.template.spec.containers[*].image}' )" for image in ${candidate_images}; do if [ "${image}" = "${EXPECTED_IMAGE}" ]; then MATCHES+=("${candidate}") break fi done done < <( kubectl get deployments \ --namespace "${NAMESPACE}" \ --output jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' ) if [ "${#MATCHES[@]}" -eq 1 ]; 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." echo "Imágenes actuales en los Deployments de ${NAMESPACE}:" kubectl get deployments \ --namespace "${NAMESPACE}" \ --output custom-columns='DEPLOYMENT:.metadata.name,IMAGES:.spec.template.spec.containers[*].image' exit 1 fi echo "Esperando que finalice el rollout de deployment/${DEPLOYMENT}..." 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 pudo determinar el contenedor que usa ${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/,$//' )" if [ -z "${SELECTOR}" ]; then echo "ERROR: no se pudo obtener el selector de deployment/${DEPLOYMENT}." exit 1 fi echo "Contenedor objetivo: ${CONTAINER_NAME}" echo "Selector de Pods: ${SELECTOR}" EXPECTED_REPLICAS="$( kubectl get deployment "${DEPLOYMENT}" \ --namespace "${NAMESPACE}" \ --output jsonpath='{.spec.replicas}' )" # Solo incluye Pods activos: Running y sin deletionTimestamp. # Así no se toma como error un Pod antiguo que todavía esté terminando. 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[@]}" -eq 0 ]; then echo "ERROR: no se encontraron Pods Running para deployment/${DEPLOYMENT}." exit 1 fi VALIDATED_PODS=0 for pod in "${PODS[@]}"; do [ -n "${pod}" ] || continue POD_IMAGE="$( kubectl get pod "${pod}" \ --namespace "${NAMESPACE}" \ --output go-template="{{range .spec.containers}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.image}}{{end}}{{end}}" )" POD_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: ${POD_READY}" echo " Imagen declarada: ${POD_IMAGE}" echo " Image ID: ${IMAGE_ID}" if [ "${POD_IMAGE}" != "${EXPECTED_IMAGE}" ]; then echo "ERROR: ${pod} ejecuta ${POD_IMAGE}; se esperaba ${EXPECTED_IMAGE}." exit 1 fi if [ "${POD_READY}" != "true" ]; then echo "ERROR: el contenedor ${CONTAINER_NAME} del Pod ${pod} no está Ready." exit 1 fi VALIDATED_PODS=$((VALIDATED_PODS + 1)) done if [ "${VALIDATED_PODS}" -ne "${EXPECTED_REPLICAS}" ]; then echo "ERROR: se esperaban ${EXPECTED_REPLICAS} Pods activos, pero se validaron ${VALIDATED_PODS}." kubectl get pods --namespace "${NAMESPACE}" --selector "${SELECTOR}" --output wide exit 1 fi echo "==================================================" echo "VALIDACIÓN EXITOSA" echo "Deployment: ${DEPLOYMENT}" echo "Pods validados: ${VALIDATED_PODS}/${EXPECTED_REPLICAS}" echo "Tag ejecutado: ${EXPECTED_IMAGE}" echo "=================================================="