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 }} run: | set -euo pipefail EXPECTED_IMAGE="${IMAGE_REPOSITORY}:${VERSION}" KUBECONFIG_PATH="${RUNNER_TEMP:-/tmp}/kubeconfig" 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 # Admite dos formatos para KUBE_CONFIG_DATA: # 1) kubeconfig YAML completo, o 2) kubeconfig codificado en Base64. if printf '%s' "${KUBE_CONFIG_DATA}" | grep -qE '^[[:space:]]*apiVersion:[[:space:]]*v1'; then echo "Formato del secret detectado: kubeconfig YAML." printf '%s\n' "${KUBE_CONFIG_DATA}" > "${KUBECONFIG_PATH}" else echo "Formato del secret detectado: Base64." KUBE_CONFIG_B64="$( printf '%s' "${KUBE_CONFIG_DATA}" | tr -d '\r\n\t ' )" if [ -z "${KUBE_CONFIG_B64}" ]; then echo "ERROR: el secret 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 son Base64." echo "No pegues KUBE_CONFIG_DATA=, comillas, prompts ni encabezados." exit 1 fi if [ $(( ${#KUBE_CONFIG_B64} % 4 )) -ne 0 ]; then echo "ERROR: la longitud del Base64 no es múltiplo de 4." echo "El valor probablemente está incompleto." exit 1 fi if ! printf '%s' "${KUBE_CONFIG_B64}" | base64 --decode > "${KUBECONFIG_PATH}" 2>/tmp/kubeconfig-base64.err; then echo "ERROR: no se pudo decodificar KUBE_CONFIG_DATA como Base64." echo "Regenera el valor con: base64 -w 0 kubeconfig-gitea.yaml" exit 1 fi fi chmod 600 "${KUBECONFIG_PATH}" export KUBECONFIG="${KUBECONFIG_PATH}" if ! grep -qE '^[[:space:]]*apiVersion:[[:space:]]*v1' "${KUBECONFIG}" \ || ! grep -qE '^[[:space:]]*clusters:' "${KUBECONFIG}" \ || ! grep -qE '^[[:space:]]*contexts:' "${KUBECONFIG}"; then echo "ERROR: el secret no produce un kubeconfig válido." exit 1 fi API_SERVER="$(awk '/^[[:space:]]*server:/ {print $2; exit}' "${KUBECONFIG}")" echo "API Server configurado: ${API_SERVER:-no detectado}" if printf '%s' "${API_SERVER}" | grep -Eq '^https?://(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|/)'; then echo "ERROR: el API Server usa una dirección loopback no accesible desde el contenedor del job." echo "Usa una IP o nombre DNS alcanzable desde el act_runner." exit 1 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 namespace "${NAMESPACE}" >/dev/null; then echo "ERROR: no hay conectividad o permisos sobre el namespace ${NAMESPACE}." exit 1 fi 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 "=================================================="