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
langfield 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.
What this module is
Section titled “What this module is”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.
What you build
Section titled “What you build”The binding contract is
labs/m07-final/SPEC.md.
Read it in full before writing code. In short:
GET /api/quote?date=YYYY-MM-DDreturns{ date, index, text, author }, whereindex = (days since 2000-01-01) mod 20and the quote comes fromquotes.json. Bad or missing dates return400Problem Details.- v2 of the API adds
"lang": "en"and changes nothing else. v1 serves the main URL, v2 sits behind acanarytag 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/pingaccepts a request only whenX-Admin-Signatureis 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/readyand 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_SHAandBUILD_IDon every revision, andLABKIT_STUDENT_IDset to your student id from the committed stack configdayquote: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.
The rules
Section titled “The rules”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:
- A new repository (or at least a new
mainbranch history) with Dayquote’s own source, tests,Dockerfileandcloudbuild.yaml. - New Pulumi projects and stacks:
dayquoteininfra/for the service and the pipeline, and a smalldayquote-secretsininfra/secrets/that owns only the admin key secret (SPEC section 5 explains why). Both use adevstack, like Sniplink. Notsniplinkrenamed, not a second service bolted onto the Sniplink stack.pulumi destroyon Dayquote must not touch Sniplink, and the other way round. - 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. quotes.jsonused unchanged. Same 20 entries, same order.- 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.
Suggested order of work
Section titled “Suggested order of work”I would do it in this order, because each step gives you something to verify before the next one can hide a mistake.
-
The function, locally (20 min).
dotnet new web, loadquotes.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. -
The image (20 min). Multi-stage Dockerfile, chiseled runtime,
InvariantGlobalization. Run it with-e PORT=8080 -p 8080:8080and call it withcurl. Ifquotes.jsonis missing in the container, you find out here and not on Cloud Run. -
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. -
The secrets stack (20 min).
infra/secrets/: the secret and one version frompulumi config set --secret adminKey, with lab key v1 from this page.pulumi upfrom your laptop. -
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. Runpulumi upfrom your laptop once, passing a hand-built image digest andrevisionSuffix=manual1with--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. -
The pipeline (35 min). Build SA, 2nd-gen repository on your existing connection, trigger with
IgnoredFiles = ["infra/secrets/**"],cloudbuild.yamlwithdotnet test, build, push andpulumi up. Commitversion: 1.0.0andtrafficMode: promote, push tomainand watch the build log until the main URL servesdayquote-api-<sha>with realCOMMIT_SHA/BUILD_ID. -
The release (25 min). Add
lang. In the same commit setversion: 2.0.0,trafficMode: canary,stableRevisionto the v1 revision name from step 6 andcanaryPercent: 0. Push. Check both URLs. From here on, do not push to the service. -
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.
Common reasons people fail
Section titled “Common reasons people fail”Each of these passes a quick local test and fails a check.
- Negative modulo.
days % 20is-1for 1999-12-31. C#%is a remainder. Thequote-before-epochcheck 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’sbodySha256tells you whether you hashed the right bytes. - Reading the key once at startup. Then
secret-rotationfails, 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-secretsproject, make the trigger ignoreinfra/secrets/**, and rotate from your laptop. - Canary at 10 %. Then one main request in ten is v2 and
main-is-v1fails randomly. Keep 0 % at check time. 400withoutdetail.Results.Problem()with nodetailargument omits it, andTypedResults.BadRequest()is not Problem Details at all.quotes.jsonnot in the image. It must be copied to publish output (CopyToPublishDirectory) or embedded as a resource./health/readyshould say503if it is missing, not crash.- Concurrency set on the service, not the template. In Cloud Run v2,
MaxInstanceRequestConcurrencyandScalingbelong to the revision template. Setting them anywhere else either fails or does not apply to the revision the probe hits.
How grading works
Section titled “How grading works”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
secretAccessoron the admin key secret only, bound on the secret, not the project. - The startup probe targets
/health/readyand the liveness probe/health/live, both defined in Pulumi. HostOptions.ShutdownTimeoutis 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.
- Copy lab key v1 from this page into your secrets stack:
cd infra/secrets && pulumi config set --secret adminKey(paste at the prompt), thenpulumi upthere. - Push to
mainuntil the trigger has deployed v1 withtrafficMode: promote, then v2 withtrafficMode: canary,stableRevisionset to the v1 revision andcanaryPercent: 0. - Press Verify. When the run pauses at
secret-rotation, copy lab key v2, set it in the secrets stack and runpulumi upthere (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
The certificate
Section titled “The certificate”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.
Clean up
Section titled “Clean up”Nothing after this module reuses the stack. Tear it all down so it stops costing cents.
export PROJECT_ID=your-course-projectexport 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 infrapulumi destroypulumi stack rm dev
# The secrets stack: secret and its versions (after the service stack,# which holds the accessor binding on this secret)cd secretspulumi destroypulumi stack rm devIf 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:
gcloud builds triggers list --region="$REGION" --project="$PROJECT_ID"# Delete anything Dayquote- or Sniplink-related that is still listedgcloud 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.
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.
What you learned
Section titled “What you learned”- 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.