Cómo implementar automáticamente los cambios de GitHub en Cloud Run con Cloud Build

1. Introducción

Descripción general

En este codelab, configurarás Cloud Run para compilar e implementar automáticamente versiones nuevas de tu aplicación cada vez que envíes cambios de código fuente a un repositorio de GitHub.

Esta aplicación de demostración guarda datos del usuario en Firestore, pero solo se guarda correctamente una parte parcial de los datos. Configurarás implementaciones continuas de modo que, cuando envíes una corrección de errores a tu repositorio de GitHub, verás automáticamente que la corrección está disponible en una revisión nueva.

Qué aprenderás

  • Escribe una aplicación web de Express con el editor de Cloud Shell
  • Conecta tu cuenta de GitHub a Google Cloud para obtener implementaciones continuas
  • Implementa automáticamente tu aplicación en Cloud Run
  • Aprende a usar HTMX y TailwindCSS

2. Configuración y requisitos

Requisitos previos

Activar Cloud Shell

  1. En la consola de Cloud, haz clic en Activar Cloud Shelld1264ca30785e435.png.

cb81e7c8e34bc8d.png

Si es la primera vez que inicias Cloud Shell, verás una pantalla intermedia que describe en qué consiste. Si apareció una pantalla intermedia, haz clic en Continuar.

d95252b003979716.png

El aprovisionamiento y la conexión a Cloud Shell solo tomará unos minutos.

7833d5e1c5d18f54.png

Esta máquina virtual está cargada con todas las herramientas de desarrollo necesarias. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que mejora considerablemente el rendimiento de la red y la autenticación. Gran parte de tu trabajo en este codelab, si no todo, se puede hacer con un navegador.

Una vez que te conectes a Cloud Shell, deberías ver que estás autenticado y que el proyecto está configurado con tu ID del proyecto.

  1. En Cloud Shell, ejecuta el siguiente comando para confirmar que tienes la autenticación:
gcloud auth list

Resultado del comando

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Ejecuta el siguiente comando en Cloud Shell para confirmar que el comando de gcloud conoce tu proyecto:
gcloud config list project

Resultado del comando

[core]
project = <PROJECT_ID>

De lo contrario, puedes configurarlo con el siguiente comando:

gcloud config set project <PROJECT_ID>

Resultado del comando

Updated property [core/project].

3. Habilita las APIs y configura las variables de entorno

Habilita las APIs

Este codelab requiere el uso de las siguientes APIs. Para habilitar esas APIs, ejecuta el siguiente comando:

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    firestore.googleapis.com \
    iamcredentials.googleapis.com

Configura variables de entorno

Puedes establecer variables de entorno que se usarán en este codelab.

REGION=<YOUR-REGION>
PROJECT_ID=<YOUR-PROJECT-ID>
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
SERVICE_ACCOUNT="firestore-accessor"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

4. Crea una cuenta de servicio

Cloud Run usará esta cuenta de servicio para llamar a la API de Gemini en Vertex AI. Esta cuenta de servicio también tendrá permisos de lectura y escritura en Firestore, y de lectura de secretos desde Secret Manager.

Primero, ejecuta este comando para crear la cuenta de servicio:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run access to Firestore"

Ahora, otorga a la cuenta de servicio acceso de lectura y escritura a Firestore.

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

5. Crea y configura un proyecto de Firebase

  1. En Firebase console, haz clic en Agregar proyecto.
  2. Ingresa <YOUR_PROJECT_ID> para agregar Firebase a uno de tus proyectos de Google Cloud existentes
  3. Si se te solicita, revisa y acepta las condiciones de Firebase.
  4. Haz clic en Continuar.
  5. Haz clic en Confirmar plan para confirmar el plan de facturación de Firebase.
  6. Habilitar Google Analytics para este codelab es opcional.
  7. Haz clic en Agregar Firebase:
  8. Cuando se haya creado el proyecto, haz clic en Continuar.
  9. En el menú Compilar, haz clic en Base de datos de Firestore.
  10. Haz clic en Crear base de datos.
  11. Elige tu región en el menú desplegable Ubicación y haz clic en Siguiente.
  12. Usa la opción predeterminada Comenzar en modo de producción y, luego, haz clic en Crear.

6. Escribe la aplicación

Primero, crea un directorio para el código fuente y desplázate a ese directorio con el comando cd.

mkdir cloud-run-github-cd-demo && cd $_

Luego, crea un archivo package.json con el siguiente contenido:

{
  "name": "cloud-run-github-cd-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "tailwind-dev": "npx tailwindcss -i ./input.css -o ./public/output.css --watch",
    "tailwind": "npx tailwindcss -i ./input.css -o ./public/output.css",
    "dev": "npm run tailwind && npm run nodemon"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/firestore": "^7.3.1",
    "axios": "^1.6.7",
    "express": "^4.18.2",
    "htmx.org": "^1.9.10"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "tailwindcss": "^3.4.1"
  }
}

Primero, crea un archivo fuente app.js con el siguiente contenido. Este archivo contiene el punto de entrada del servicio y la lógica principal de la app.

const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const path = require("path");
const { get } = require("axios");

const { Firestore } = require("@google-cloud/firestore");
const firestoreDb = new Firestore();

const fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

const service = process.env.K_SERVICE;
const revision = process.env.K_REVISION;

app.use(express.static("public"));

app.get("/edit", async (req, res) => {
    res.send(`<form hx-post="/update" hx-target="this" hx-swap="outerHTML">
                <div>
  <p>
    <label>Name</label>    
    <input class="border-2" type="text" name="name" value="Cloud">
    </p><p>
    <label>Town</label>    
    <input class="border-2" type="text" name="town" value="Nibelheim">
    </p>
  </div>
  <div class="flex items-center mr-[10px] mt-[10px]">
  <button class="btn bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]">Submit</button>
  <button class="btn bg-gray-200 text-gray-800 px-4 py-2 rounded-lg text-center text-sm font-medium mr-[10px]" hx-get="cancel">Cancel</button>  
                ${spinnerSvg} 
                </div>
  </form>`);
});

app.post("/update", async function (req, res) {
    let name = req.body.name;
    let town = req.body.town;
    const doc = firestoreDb.doc(`demo/${name}`);

    //TODO: fix this bug
    await doc.set({
        name: name
        /* town: town */
    });

    res.send(`<div hx-target="this" hx-swap="outerHTML" hx-indicator="spinner">
                <p>
                <div><label>Name</label>: ${name}</div>
                </p><p>
                <div><label>Town</label>: ${town}</div>
                </p>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>               
            </div>`);
});

app.get("/cancel", (req, res) => {
    res.send(`<div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>`);
});

const port = parseInt(process.env.PORT) || 8080;
app.listen(port, async () => {
    console.log(`booth demo: listening on port ${port}`);

    //serviceMetadata = helper();
});

app.get("/helper", async (req, res) => {
    let region = "";
    let projectId = "";
    let div = "";

    try {
        // Fetch the token to make a GCF to GCF call
        const response1 = await get(
            "http://metadata.google.internal/computeMetadata/v1/project/project-id",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        // Fetch the token to make a GCF to GCF call
        const response2 = await get(
            "http://metadata.google.internal/computeMetadata/v1/instance/region",
            {
                headers: {
                    "Metadata-Flavor": "Google"
                }
            }
        );

        projectId = response1.data;
        let regionFull = response2.data;
        const index = regionFull.lastIndexOf("/");
        region = regionFull.substring(index + 1);

        div = `
        <div>
        This created the revision <code>${revision}</code> of the 
        Cloud Run service <code>${service}</code> in <code>${region}</code>
        for project <code>${projectId}</code>.
        </div>`;
    } catch (ex) {
        // running locally
        div = `<div> This is running locally.</div>`;
    }

    res.send(div);
});

Crea un archivo llamado spinnerSvg.js.

module.exports.spinnerSvg = `<svg id="spinner" alt="Loading..."
                    class="htmx-indicator animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                >
                    <circle
                        class="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                    ></circle>
                    <path
                        class="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path>
                </svg>`;

Cómo crear un archivo input.css para tailwindCSS

@tailwind base;
@tailwind components;
@tailwind utilities;

Y crea el archivo tailwind.config.js para tailwindCSS

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

Y crea un archivo .gitignore.

node_modules/

npm-debug.log
coverage/

package-lock.json

.DS_Store

Ahora, crea un directorio public nuevo.

mkdir public
cd public

En ese directorio público, crea el archivo index.html para el frontend, que usará htmx.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <script
            src="https://unpkg.com/htmx.org@1.9.10"
            integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
            crossorigin="anonymous"
        ></script>

        <link href="./output.css" rel="stylesheet" />
        <title>Demo 1</title>
    </head>
    <body
        class="font-sans bg-body-image bg-cover bg-center leading-relaxed"
    >
        <div class="container max-w-[700px] mt-[50px] ml-auto mr-auto">
            <div class="hero flex items-center">                    
                <div class="message text-base text-center mb-[24px]">
                    <h1 class="text-2xl font-bold mb-[10px]">
                        It's running!
                    </h1>
                    <div class="congrats text-base font-normal">
                        Congratulations, you successfully deployed your
                        service to Cloud Run. 
                    </div>
                </div>
            </div>

            <div class="details mb-[20px]">
                <p>
                    <div hx-trigger="load" hx-get="/helper" hx-swap="innerHTML" hx-target="this">Hello</div>                   
                </p>
            </div>

            <p
                class="callout text-sm text-blue-700 font-bold pt-4 pr-6 pb-4 pl-10 leading-tight"
            >
                You can deploy any container to Cloud Run that listens for
                HTTP requests on the port defined by the
                <code>PORT</code> environment variable. Cloud Run will
                scale automatically based on requests and you never have to
                worry about infrastructure.
            </p>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                Persistent Storage Example using Firestore
            </h1>
            <div hx-target="this" hx-swap="outerHTML">
                <p>
                <div><label>Name</label>: Cloud</div>
                </p><p>
                <div><label>Town</label>: Nibelheim</div>
                </p>
                <div>
                <button
                    hx-get="/edit"
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium mt-[10px]"
                >
                    Click to update
                </button>                
                </div>
            </div>

            <h1 class="text-2xl font-bold mt-[40px] mb-[20px]">
                What's next
            </h1>
            <p class="next text-base mt-4 mb-[20px]">
                You can build this demo yourself!
            </p>
            <p class="cta">
                <button
                    class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                >
                    VIEW CODELAB
                </button>
            </p> 
        </div>
   </body>
</html>

7. Ejecución de la aplicación de manera local

En esta sección, ejecutarás la aplicación de manera local para confirmar que hay un error en ella cuando el usuario intente guardar datos.

En primer lugar, deberás tener el rol de usuario de Datastore para acceder a Firestore (si usas tu identidad para la autenticación, p.ej., si ejecutas Cloud Shell) o puedes usar la identidad de la cuenta de usuario creada anteriormente.

Usa ADC en la ejecución local

Si estás ejecutando en Cloud Shell, ya estás ejecutando en una máquina virtual de Google Compute Engine. Las credenciales asociadas con esta máquina virtual (como se muestra cuando se ejecuta gcloud auth list) se utilizarán automáticamente en Credenciales predeterminadas de la aplicación (ADC), por lo que no es necesario utilizar el comando gcloud auth application-default login. Sin embargo, tu identidad aún necesitará el rol de usuario de Datastore. Puedes pasar a la sección Cómo ejecutar la app de manera local.

Sin embargo, si estás ejecutando en tu terminal local (es decir, no en Cloud Shell), deberás usar las credenciales predeterminadas de la aplicación para autenticarte en las APIs de Google. Puedes 1) acceder con tus credenciales (siempre que tengas el rol Usuario de Datastore) o 2) acceder usando la identidad de la cuenta de servicio que se usa en este codelab.

Opción 1) Usa tus credenciales para ADC

Si quieres usar tus credenciales, primero puedes ejecutar gcloud auth list para verificar cómo te autenticas en gcloud. A continuación, es posible que debas otorgar a tu identidad el rol de usuario de Vertex AI. Si tu identidad tiene el rol de propietario, ya tienes este rol de usuario de Datastore. De lo contrario, puedes ejecutar este comando para otorgar a tu identidad el rol de usuario de Vertex AI y el rol de usuario de Datastore.

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/datastore.user

Luego, ejecuta el siguiente comando:

gcloud auth application-default login

Opción 2: Suplantación de identidad de una cuenta de servicio para ADC

Si deseas usar la cuenta de servicio que se creó en este codelab, tu cuenta de usuario deberá tener el rol de creador de tokens de cuentas de servicio. Para obtener este rol, ejecuta el siguiente comando:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/iam.serviceAccountTokenCreator

Luego, ejecutarás el siguiente comando para usar ADC con la cuenta de servicio

gcloud auth application-default login --impersonate-service-account=$SERVICE_ACCOUNT_ADDRESS

Ejecuta la app de forma local

A continuación, asegúrate de estar en el directorio raíz cloud-run-github-cd-demo de tu codelab.

cd .. && pwd

Ahora, instalarás las dependencias.

npm install

Por último, puedes iniciar la app ejecutando la siguiente secuencia de comandos. Esta secuencia de comandos también generará el archivo output.css desde tailwindCSS.

npm run dev

Ahora abre tu navegador web en http://localhost:8080. Si estás en Cloud Shell, puedes abrir el botón Vista previa en la Web y seleccionar Puerto de vista previa 8080 para abrir el sitio web.

Vista previa en la Web: vista previa en el botón del puerto 8080

Escribe el texto en los campos de entrada Nombre y Ciudad, y presiona Guardar. Luego, actualiza la página. Notarás que el campo de ciudad no persistió. Solucionarás este error en la siguiente sección.

Detén la ejecución de la app Express de manera local (p.ej., Ctrl^c en MacOS).

8. Crea un repositorio de GitHub

En tu directorio local, crea un repositorio nuevo con main como nombre predeterminado de rama.

git init
git branch -M main

Confirma la base de código actual que contiene el error. Corregirás el error después de que se configure la implementación continua.

git add .
git commit -m "first commit for express application"

Ve a GitHub y crea un repositorio vacío que sea privado o público. En este codelab, se recomienda asignarle el nombre cloud-run-auto-deploy-codelab a tu repositorio. Para crear un repositorio vacío, dejarás todos los parámetros de configuración predeterminados sin marcar o establecidos en ninguno para que no haya contenido en el repositorio de forma predeterminada cuando lo crees, p. ej.,

Configuración predeterminada de GitHub

Si completaste este paso correctamente, verás las siguientes instrucciones en la página del repositorio vacío:

Instrucciones vacías para el repositorio de GitHub

Para seguir las instrucciones para enviar un repositorio existente desde la línea de comandos, ejecuta los siguientes comandos:

Primero, agrega el repositorio remoto ejecutando

git remote add origin <YOUR-REPO-URL-PER-GITHUB-INSTRUCTIONS>

y, luego, enviar la rama principal al repositorio upstream.

git push -u origin main

9. Configura la implementación continua

Ahora que tienes código en un GitHub, puedes configurar la implementación continua. Ve a la consola de Cloud para Cloud Run.

  • Haz clic en Crear un servicio.
  • Haz clic en Continuously deploy from a repository.
  • Haz clic en CONFIGURAR CLOUD Build.
  • En Repositorio de código fuente
    • Selecciona GitHub como el proveedor del repositorio
    • Haz clic en Administrar repositorios conectados para configurar el acceso de Cloud Build al repositorio.
    • Selecciona tu repositorio y haz clic en Siguiente.
  • En Configuración de compilación,
    • Dejar la rama como ^main$
    • En Tipo de compilación, selecciona Go, Node.js, Python, Java, .NET Core, Ruby o PHP a través de los paquetes de compilación de Google Cloud.
  • Deja el directorio de contexto de compilación como /
  • Haga clic en Guardar.
  • En Autenticación
    • Haz clic en Permitir invocaciones no autenticadas.
  • En Contenedores, Volúmenes, Herramientas de redes y Seguridad
    • En la pestaña Seguridad, selecciona la cuenta de servicio que creaste en un paso anterior, p.ej., Cloud Run access to Firestore
  • Haz clic en CREAR.

Esto implementará el servicio de Cloud Run que contiene el error que corregirás en la siguiente sección.

10. Corregir el error

Corrige el error en el código

En el editor de Cloud Shell, abre el archivo app.js y ve al comentario que dice //TODO: fix this bug.

cambia la siguiente línea de

 //TODO: fix this bug
    await doc.set({
        name: name
    });

para

//fixed town bug
    await doc.set({
        name: name,
        town: town
    });

Ejecuta este comando para verificar la solución

npm run start

y abre tu navegador web. Vuelve a guardar datos para la ciudad y actualiza la página. Verás que los datos de la ciudad recién ingresados persisten correctamente al actualizar.

Ahora que verificaste la solución, tienes todo listo para implementarla. Primero, confirma la corrección.

git add .
git commit -m "fixed town bug"

y, luego, enviarla al repositorio upstream en GitHub.

git push origin main

Cloud Build implementará automáticamente los cambios. Puedes ir a la consola de Cloud para que tu servicio de Cloud Run supervise los cambios en la implementación.

Verifica la corrección en producción

Una vez que la consola de Cloud para tu servicio de Cloud Run muestre que una segunda revisión está entregando el 100% del tráfico, p.ej., https://console.cloud.google.com/run/detail/<YOUR_REGION>/<YOUR_SERVICE_NAME>/revisions, puedes abrir la URL del servicio de Cloud Run en tu navegador y verificar que los datos de la ciudad recién ingresados se mantengan después de actualizar la página.

11. ¡Felicitaciones!

¡Felicitaciones por completar el codelab!

Te recomendamos que revises la documentación de Cloud Run y la implementación continua desde git.

Temas abordados

  • Escribe una aplicación web de Express con el editor de Cloud Shell
  • Conecta tu cuenta de GitHub a Google Cloud para obtener implementaciones continuas
  • Implementa automáticamente tu aplicación en Cloud Run
  • Aprende a usar HTMX y TailwindCSS

12. Limpia

Para evitar cargos involuntarios (por ejemplo, si los servicios de Cloud Run se invocan de forma involuntaria más veces que tu asignación mensual de invocación de Cloud Run en el nivel gratuito), puedes borrar Cloud Run o el proyecto que creaste en el paso 2.

Para borrar el servicio de Cloud Run, ve a la consola de Cloud Run en https://console.cloud.google.com/run y borra el servicio que creaste en este codelab; p.ej., borra el servicio cloud-run-auto-deploy-codelab.

Si decides borrar el proyecto completo, puedes ir a https://console.cloud.google.com/cloud-resource-manager, seleccionar el proyecto que creaste en el paso 2 y elegir Borrar. Si borras el proyecto, deberás cambiar los proyectos en tu SDK de Cloud. Para ver la lista de todos los proyectos disponibles, ejecuta gcloud projects list.