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' 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 "==================================================" 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 ' )" 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 if ! printf '%s' "${K8S_CA_B64}" | grep -Eq '^[A-Za-z0-9+/]*={0,2}$'; then echo "ERROR: K8S_CA_B64 contiene caracteres que no pertenecen a Base64." exit 1 fi CA_REMAINDER=$(( ${#K8S_CA_B64} % 4 )) case "${CA_REMAINDER}" in 0) ;; 2) K8S_CA_B64="${K8S_CA_B64}==" ;; 3) K8S_CA_B64="${K8S_CA_B64}=" ;; 1) echo "ERROR: K8S_CA_B64 está truncado o dañado." exit 1 ;; esac 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}" if [ -n "${K8S_TLS_SERVER_NAME:-}" ]; then echo " tls-server-name: ${K8S_TLS_SERVER_NAME}" fi 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}" chmod 600 "${KUBECONFIG_PATH}" export KUBECONFIG="${KUBECONFIG_PATH}" echo "API Server configurado: ${K8S_SERVER}" if [ -n "${K8S_TLS_SERVER_NAME:-}" ]; then echo "TLS server name configurado: ${K8S_TLS_SERVER_NAME}" fi 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 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 "=================================================="