Skip to content

Signed in as

Sign in to Curious Dev Learn

Sign in to verify labs, save your progress and get a certificate. Lessons stay open without an account.

Module 073 h 15 minLab

Final project: Dayquote

Build, ship and release a new .NET service on Cloud Run from an empty folder, using everything from modules 1 to 6, with no step-by-step help.

From: your team lead Subject: Dayquote, before Friday

The intranet homepage wants a “quote of the day” box. Product wants it deterministic (same day, same quote, everywhere), the mobile team wants a lang field next release without breaking the current app, and security wants an admin endpoint that nobody can call without a signature. Same rules as Sniplink: everything as code, keyless CI, no console clicking. Spec is attached. I will not be reviewing the steps, only the result.

No incident log this time. Nothing is broken yet, because nothing exists yet.

Modules 1 to 6 walked you through every decision with Sniplink. This one does not. You get a spec, a quote file and a set of automated checks, and you build Dayquote from an empty folder: a stateless API that returns one of 20 quotes for any date.

The app is deliberately small. Its business logic fits in thirty lines, so nearly all of your three hours go where the course is: the image, the identity, the secret, the lifecycle settings, the release and the pipeline. That is the point. In a real job the endpoint is the easy part.

The binding contract is labs/m07-final/SPEC.md. Read it in full before writing code. In short:

  • GET /api/quote?date=YYYY-MM-DD returns { date, index, text, author }, where index = (days since 2000-01-01) mod 20 and the quote comes from quotes.json. Bad or missing dates return 400 Problem Details.
  • v2 of the API adds "lang": "en" and changes nothing else. v1 serves the main URL, v2 sits behind a canary tag at 0 %, using the module 6 traffic model: one revision per build (dayquote-api-<shortSha>), traffic from committed stack config (trafficMode, stableRevision, canaryPercent).
  • POST /api/admin/ping accepts a request only when X-Admin-Signature is the hex HMAC-SHA256 of the raw body, keyed with an admin key that the service reads from a Secret Manager volume at request time.
  • /health/live, /health/ready and LabKit.
  • A chiseled, non-root image; a dedicated runtime service account; concurrency 20; at most 2 instances; startup and liveness probes; graceful shutdown.
  • Built and deployed only by a Cloud Build trigger on your public GitHub repo, with APP_VERSION, COMMIT_SHA and BUILD_ID on every revision, and LABKIT_STUDENT_ID set to your student id from the committed stack config dayquote:studentId.

The admin key is the lab key shown on this lab’s page, the same mechanism you used in module 3, with a fresh key for this lab. Because the platform issued the key, it can sign admin pings itself and check that you accept a correct signature and reject a wrong one. You never send it anything secret, and it never asks your service to reveal the key.

Reuse is allowed and encouraged. Copy your Dockerfile, your cloudbuild.yaml, your Pulumi component patterns and your ILabSecretSource from Sniplink. Engineers do not rewrite working infrastructure for sport.

But it must be a new service and a new stack. Concretely:

  1. A new repository (or at least a new main branch history) with Dayquote’s own source, tests, Dockerfile and cloudbuild.yaml.
  2. New Pulumi projects and stacks: dayquote in infra/ for the service and the pipeline, and a small dayquote-secrets in infra/secrets/ that owns only the admin key secret (SPEC section 5 explains why). Both use a dev stack, like Sniplink. Not sniplink renamed, not a second service bolted onto the Sniplink stack. pulumi destroy on Dayquote must not touch Sniplink, and the other way round.
  3. A new Cloud Run service whose name starts with dayquote-, a new runtime service account, a new secret, a new trigger. The checks look for these.
  4. quotes.json used unchanged. Same 20 entries, same order.
  5. No service deploys from your laptop. Your laptop may run pulumi preview, local Docker builds, the secrets stack, and the one-time apply of the platform resources (build SA, repository link, trigger), exactly as in module 6. The service itself is deployed by the trigger.

Using the same course project as Sniplink is fine, and simplest. If you already deleted it, re-run the module 0 bootstrap script and the one-time GitHub app install from module 6 first.

I would do it in this order, because each step gives you something to verify before the next one can hide a mistake.

  1. The function, locally (20 min). dotnet new web, load quotes.json, write the index calculation and the endpoint. Write xUnit tests for the four worked examples in the spec, including 1999-12-31, before you trust your modulo. Add Problem Details for bad input.

  2. The image (20 min). Multi-stage Dockerfile, chiseled runtime, InvariantGlobalization. Run it with -e PORT=8080 -p 8080:8080 and call it with curl. If quotes.json is missing in the container, you find out here and not on Cloud Run.

  3. The admin endpoint (25 min). Read the key from a file path in configuration, read the raw body, compare in constant time. Test it locally with a key file on disk and a signature from openssl dgst -sha256 -hmac.

  4. The secrets stack (20 min). infra/secrets/: the secret and one version from pulumi config set --secret adminKey, with lab key v1 from this page. pulumi up from your laptop.

  5. The service stack, by hand once (30 min). infra/: Artifact Registry repo, runtime SA, accessor binding on the secret, Cloud Run service with the volume, probes, scaling, the module 6 traffic code and the env vars. Run pulumi up from your laptop once, passing a hand-built image digest and revisionSuffix=manual1 with --config (as the pipeline will), to prove the stack works. Rule 5 is about the final state, not about the first try; step 6 replaces that revision.

  6. The pipeline (35 min). Build SA, 2nd-gen repository on your existing connection, trigger with IgnoredFiles = ["infra/secrets/**"], cloudbuild.yaml with dotnet test, build, push and pulumi up. Commit version: 1.0.0 and trafficMode: promote, push to main and watch the build log until the main URL serves dayquote-api-<sha> with real COMMIT_SHA/BUILD_ID.

  7. The release (25 min). Add lang. In the same commit set version: 2.0.0, trafficMode: canary, stableRevision to the v1 revision name from step 6 and canaryPercent: 0. Push. Check both URLs. From here on, do not push to the service.

  8. Verify and rotate (20 min). Press Verify, follow the rotation prompt, fix what fails.

That adds up to 3h 15m for someone who finished modules 1 to 6 recently. If you took a long break, add an hour for re-reading your own Sniplink code.

Each of these passes a quick local test and fails a check.

  • Negative modulo. days % 20 is -1 for 1999-12-31. C# % is a remainder. The quote-before-epoch check exists for exactly this.
  • Hashing re-serialized JSON. Binding the admin body to a record and then hashing JsonSerializer.Serialize(record) produces different bytes than the platform sent. Read the raw body. The response’s bodySha256 tells you whether you hashed the right bytes.
  • Reading the key once at startup. Then secret-rotation fails, because the same revision keeps the old key. Read the file per request or cache it for a few seconds.
  • Rotation creates a new revision. If adding the new key version goes through the main trigger, the build creates a new revision and the check sees a different one. Keep the secret versions in the dayquote-secrets project, make the trigger ignore infra/secrets/**, and rotate from your laptop.
  • Canary at 10 %. Then one main request in ten is v2 and main-is-v1 fails randomly. Keep 0 % at check time.
  • 400 without detail. Results.Problem() with no detail argument omits it, and TypedResults.BadRequest() is not Problem Details at all.
  • quotes.json not in the image. It must be copied to publish output (CopyToPublishDirectory) or embedded as a resource. /health/ready should say 503 if it is missing, not crash.
  • Concurrency set on the service, not the template. In Cloud Run v2, MaxInstanceRequestConcurrency and Scaling belong to the revision template. Setting them anywhere else either fails or does not apply to the revision the probe hits.

Paste three inputs on the lab page: the main service URL, the canary--- tag URL and your public GitHub repo URL. The platform runs 32 checks in the order listed in the spec: the ID token, the contract tests, versions on both URLs, identity, the secret proof and admin pings, rotation, the concurrency probe, the build provenance, and the image checks.

A check marked blocking stops the run when it fails, because everything after it would fail for the same reason: the token, the service name and the canary tag. All other checks run and report independently, each with a one-line hint.

Several checks are self-reported: the versions, the commit and build IDs, the concurrency counters, and the four image checks (non-root user, no shell, invariant globalization, .NET 10). They trust LabKit inside your container, so a service could lie. The optional Verified tier, which comes later, will read the image and the Cloud Run config directly. Three requirements cannot be observed from outside at all, so they are on your honour as a self-check list:

  • The runtime SA has secretAccessor on the admin key secret only, bound on the secret, not the project.
  • The startup probe targets /health/ready and the liveness probe /health/live, both defined in Pulumi.
  • HostOptions.ShutdownTimeout is below Cloud Run’s 10-second grace period and you have watched a deploy finish without failed requests.

Goal: Dayquote v1 on the main URL and v2 on the canary tag, deployed by your Cloud Build trigger, passing every check in SPEC.md.

  1. Copy lab key v1 from this page into your secrets stack: cd infra/secrets && pulumi config set --secret adminKey (paste at the prompt), then pulumi up there.
  2. Push to main until the trigger has deployed v1 with trafficMode: promote, then v2 with trafficMode: canary, stableRevision set to the v1 revision and canaryPercent: 0.
  3. Press Verify. When the run pauses at secret-rotation, copy lab key v2, set it in the secrets stack and run pulumi up there (this adds the new version and disables the old one), wait for your cache window, then press Continue. Do not push to the service repo in between.

Check your lab

Loading your lab…

Sign in to verify this lab and save your progress. Everything above works without an account.

  • Your Dayquote main service URL
  • Your canary tag URL (https://canary---…run.app)
  • Your public GitHub repository URL
  • Lab key v1

32 checks run against the service you deployed.

Some checks are self-reported: the checker trusts what your service says about itself and verifies the rest from the outside.

Passing m07-final with m01 to m06 already passed issues the certificate Containerizing & Deploying .NET on Google Cloud on your profile at learn.vladtimchenko.dev. It has a public verification link you can put on LinkedIn or a CV. The certificate records which labs passed and when; it does not record your project ID or anything from your service.

It certifies “Completed”: a real service ran in your project and passed external checks. It is an independent course certificate, not a Google certification.

Nothing after this module reuses the stack. Tear it all down so it stops costing cents.

Terminal window
export PROJECT_ID=your-course-project
export REGION=europe-west1
# The service stack: Cloud Run, runtime SA, Artifact Registry repo, trigger.
# Like Sniplink after module 6, it needs the per-build config to evaluate;
# pass the deployed values with --config if it asks for dayquote:image.
cd infra
pulumi destroy
pulumi stack rm dev
# The secrets stack: secret and its versions (after the service stack,
# which holds the accessor binding on this secret)
cd secrets
pulumi destroy
pulumi stack rm dev

If you created the Cloud Build trigger in the service stack, pulumi destroy removes it. Check that nothing is left, because a forgotten trigger still runs on every push:

Terminal window
gcloud builds triggers list --region="$REGION" --project="$PROJECT_ID"
# Delete anything Dayquote- or Sniplink-related that is still listed
gcloud builds triggers delete TRIGGER_NAME --region="$REGION" --project="$PROJECT_ID"

Do the same for Sniplink if you kept it after module 6.

Then consider deleting the whole course project. It is the cleanest way to be sure the KMS key versions, the state bucket and any image left in Artifact Registry stop billing, and it is why module 0 asked for a course-only project. The project stays recoverable for about 30 days.

Terminal window
gcloud projects delete "$PROJECT_ID"

Keep the project instead if you plan to take the Verified tier when it launches; it reads the same project.

  • You can take a .NET service from an empty folder to Cloud Run with every setting chosen on purpose, not copied from a tutorial.
  • A spec with worked examples and edge dates catches the bugs that local happy paths never do.
  • Runtime secret reads, a dedicated identity and keyless CI fit together into one deploy path you can explain line by line.
  • Additive API changes plus a 0 % canary tag let two versions coexist safely.
  • Verification from outside has limits; you know which of your claims are proven and which are self-reported.