Arcade Voyage: App Modernization

Solution for Arcade Voyage: App Modernization. 1 lab: GSP644. Fast copy-paste commands for Google Cloud.

GSP644 — Build a Serverless App with Cloud Run that Creates PDF Files

Estimated time: 25 minutes

# 🚀 Build a Serverless App with Cloud Run that Creates PDF Files > ⚠️ **Disclaimer:** This is an independent, community-made walkthrough created for educational purposes, hands-on practice, and Google Cloud certification preparation. This guide is designed to help learners understand Google Cloud services and complete practical exercises. Always attempt the lab yourself first and follow Google Cloud Skills Boost / Qwiklabs Terms of Service. This walkthrough is not affiliated with or endorsed b

clear
CYAN='\e[1;36m'
BLUE='\e[1;34m'
RESET='\e[0m'
BOLD='\e[1m'
GREEN='\e[1;32m'
YELLOW='\e[1;33m'
MAGENTA='\e[1;35m'

echo -e "${CYAN}${BOLD}"
cat << "EOF"
  ____        _     _ _            __   ___              
 / __ \      | |   (_) |          / _| / _ \             
| |  | |_ __| |__   _| |_  ___   | |_ | | | |_ __  ___   
| |  | | '__| '_ \| | __| / _ \  |  _|| | | | '_ \/ __|  
| |__| | |  | |_) | | |_ | (_) | | |  | |_| | |_) \__ \  
 \____/|_|  |_.__/|_|\__| \___/  |_|   \___/| .__/|___/  
                                             | |         
                                             |_|         
EOF
echo -e "${RESET}"
echo -e "${BLUE}${BOLD}╔════════════════════════════════════════════════════════════╗${RESET}"
echo -e "${BLUE}${BOLD}║    🚀 BROUGHT TO YOU BY ORBIT OF OPS                       ║${RESET}"
echo -e "${BLUE}${BOLD}╚════════════════════════════════════════════════════════════╝${RESET}\n"

echo -e "${MAGENTA}${BOLD}Please check your lab instructions panel for your assigned Region.${RESET}"
read -p "Enter your Region (e.g., us-east4): " REGION
echo ""

export PROJECT_ID=$(gcloud config get-value core/project 2>/dev/null)

echo -e "${CYAN}Task 2: Enabling Cloud Run, Cloud Build, and Pub/Sub APIs...${RESET}"
gcloud services enable run.googleapis.com cloudbuild.googleapis.com pubsub.googleapis.com --quiet

echo -e "${CYAN}Task 3a: Cloning repo and installing packages...${RESET}"
cd ~
rm -rf pet-theory
git clone https://github.com/rosera/pet-theory.git >/dev/null 2>&1
cd pet-theory/lab03

# Safely inject the "start" script into package.json using jq
jq '.scripts.start="node index.js"' package.json > tmp.json && mv tmp.json package.json
npm install express body-parser child_process @google-cloud/storage --quiet >/dev/null 2>&1

echo -e "${CYAN}Task 3b: Building initial V1 Docker container...${RESET}"
gcloud builds submit --tag gcr.io/$PROJECT_ID/pdf-converter --quiet

echo -e "${CYAN}Task 3c: Deploying V1 to Cloud Run...${RESET}"
gcloud run deploy pdf-converter \
  --image gcr.io/$PROJECT_ID/pdf-converter \
  --platform managed \
  --region $REGION \
  --no-allow-unauthenticated \
  --max-instances=1 \
  --quiet

export SERVICE_URL=$(gcloud run services describe pdf-converter --region $REGION --format="value(status.url)")

echo -e "${CYAN}Task 4a: Creating Cloud Storage Buckets...${RESET}"
gcloud storage buckets create gs://$PROJECT_ID-upload --quiet
gcloud storage buckets create gs://$PROJECT_ID-processed --quiet

echo -e "${CYAN}Task 4b: Creating Pub/Sub Topics & Notifications...${RESET}"
gcloud storage buckets notifications create -t new-doc -f json -e OBJECT_FINALIZE gs://$PROJECT_ID-upload --quiet

echo -e "${CYAN}Task 4c: Configuring IAM Service Accounts and Permissions...${RESET}"
gcloud iam service-accounts create pubsub-cloud-run-invoker --display-name "PubSub Cloud Run Invoker" --quiet
gcloud run services add-iam-policy-binding pdf-converter \
  --member=serviceAccount:pubsub-cloud-run-invoker@$PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/run.invoker \
  --region $REGION \
  --quiet

export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud beta services identity create --service=pubsub.googleapis.com --project="$PROJECT_ID" --quiet

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member=serviceAccount:[email protected] \
  --role=roles/iam.serviceAccountTokenCreator \
  --quiet >/dev/null

echo -e "${CYAN}Task 4d: Creating Event Subscription to trigger Cloud Run...${RESET}"
gcloud pubsub subscriptions create pdf-conv-sub \
  --topic new-doc \
  --push-endpoint=$SERVICE_URL \
  --push-auth-service-account=pubsub-cloud-run-invoker@$PROJECT_ID.iam.gserviceaccount.com \
  --quiet

echo -e "\n${GREEN}${BOLD}✅ Phase 1 Complete! Click 'Check my progress' on the first 4 tasks, then proceed to Command 2.${RESET}"
CYAN='\e[1;36m'
BLUE='\e[1;34m'
RESET='\e[0m'
BOLD='\e[1m'
GREEN='\e[1;32m'
YELLOW='\e[1;33m'

echo -e "${BLUE}${BOLD}╔════════════════════════════════════════════════════════════╗${RESET}"
echo -e "${BLUE}${BOLD}║    🚀 COMMAND 2 OF 2: CONTAINER UPGRADE & TESTING          ║${RESET}"
echo -e "${BLUE}${BOLD}╚════════════════════════════════════════════════════════════╝${RESET}\n"

export PROJECT_ID=$(gcloud config get-value core/project 2>/dev/null)

echo -e "${CYAN}Task 6a: Upgrading Dockerfile to include LibreOffice...${RESET}"
cat << 'EOF' > Dockerfile
FROM node:20
RUN apt-get update -y \
    && apt-get install -y libreoffice \
    && apt-get clean
WORKDIR /usr/src/app
COPY package.json package*.json ./
RUN npm install --only=production
COPY . .
CMD [ "npm", "start" ]
EOF

echo -e "${CYAN}Task 6b: Upgrading index.js logic...${RESET}"
cat << 'EOF' > index.js
const { promisify } = require("util");
const { Storage } = require("@google-cloud/storage");
const exec = promisify(require("child_process").exec);
const storage = new Storage();
const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs"); // Fixing missing module dependency from Google's code
const app = express();

app.use(bodyParser.json());
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log("Listening on port", port);
});

app.post("/", async (req, res) => {
  try {
    const file = decodeBase64Json(req.body.message.data);
    await downloadFile(file.bucket, file.name);
    const pdfFileName = await convertFile(file.name);
    await uploadFile(process.env.PDF_BUCKET, pdfFileName);
    await deleteFile(file.bucket, file.name);
  } catch (ex) {
    console.log(`Error: ${ex}`);
  }
  res.set("Content-Type", "text/plain");
  res.send("\n\nOK\n\n");
});

function decodeBase64Json(data) {
  return JSON.parse(Buffer.from(data, "base64").toString());
}

async function fileExists(filePath) {
  try {
    await fs.promises.access(filePath);
    return true;
  } catch (err) {
    return false;
  }
}

async function downloadFile(bucketName, fileName) {
  const fileExistsLocally = await fileExists(`/tmp/${fileName}`);
  if (fileExistsLocally) {
    console.log(`File exists locally. Deleting: ${fileName}`);
    await fs.promises.unlink(`/tmp/${fileName}`);
    console.log(`File deleted.`);
  } else {
    console.log(`File does not exist locally: ${fileName}`);
  }
  const options = { destination: `/tmp/${fileName}` };
  await storage.bucket(bucketName).file(fileName).download(options);
  console.log(`File downloaded: ${fileName}`);
}

async function convertFile(fileName) {
  const cmd =
    "libreoffice --headless --convert-to pdf --outdir /tmp " +
    `"/tmp/${fileName}"`;
  console.log(cmd);
  const { stdout, stderr } = await exec(cmd);
  if (stderr) {
    console.log(`Conversion Failed: ${stderr}`);
    throw stderr;
  }
  console.log(`Conversion Success: ${stdout}`);
  pdfFileName = fileName.replace(/\.\w+$/, ".pdf");
  return pdfFileName;
}

async function deleteFile(bucketName, fileName) {
  await storage.bucket(bucketName).file(fileName).delete();
}

async function uploadFile(bucketName, fileName) {
  await storage.bucket(bucketName).upload(`/tmp/${fileName}`);
}
EOF

echo -e "${CYAN}Task 6c: Building V2 Docker Container (This takes 2-3 minutes due to LibreOffice)...${RESET}"
gcloud builds submit --tag gcr.io/$PROJECT_ID/pdf-converter --quiet

echo -e "${CYAN}Task 6d: Deploying V2 to Cloud Run with 2Gi Memory Limits...${RESET}"
gcloud run deploy pdf-converter \
  --image gcr.io/$PROJECT_ID/pdf-converter \
  --platform managed \
  --region $REGION \
  --memory=2Gi \
  --no-allow-unauthenticated \
  --max-instances=1 \
  --set-env-vars PDF_BUCKET=$PROJECT_ID-processed \
  --quiet

echo -e "${CYAN}Task 5/6: Uploading test invoices to trigger the conversion pipeline...${RESET}"
gcloud storage cp gs://spls/gsp644/* gs://$PROJECT_ID-upload --quiet

echo -e "\n${GREEN}${BOLD}🎉 AUTOMATION COMPLETE! Click 'Check my progress' on the remaining tasks to collect your 100% score!${RESET}"
echo -e "${GREEN}${BOLD}# ─────────────────────────────────────────────────────────────${RESET}"
echo -e "${GREEN}${BOLD}#  Lab cleared? Like the video and subscribe to Orbit of Ops 🚀${RESET}"
echo -e "${GREEN}${BOLD}#  youtube.com/@orbitofops${RESET}"
echo -e "${GREEN}${BOLD}# ─────────────────────────────────────────────────────────────${RESET}"