Skip to content

Deep Dive: Sample Apps

apps/ holds the canonical, frozen source of the four GitLab projects that ship in the blueprint-apps/ group. They are the ultimate "demo" — once Phase 3 is green, a git push to any of them triggers a Runner-driven pipeline that builds the image, pushes it to the in-cluster registry, and helm upgrade --installs the chart into the cluster.

Overview

The four apps:

App GitLab project Builds image? Has tests? Helm chart? Notes
shared-code blueprint-apps/shared-code no no no Cross-app CI templates; consumed via include: from the other 3.
guestbook blueprint-apps/guestbook yes (kaniko on Go source) no yes Classic k8s guestbook: Go app + Dockerfile + chart.
redis blueprint-apps/redis no (uses upstream redis:7.4.1) no yes Demo redis master. Namespaced away from infra redis to avoid chart-shadowing.
redis-slave blueprint-apps/redis-slave no (upstream redis:7.4.1) no yes Demo redis slave.

All four are deployed via the same per-project pipeline template (apps/shared-code/templates/per-project-app.gitlab-ci.yml.tpl for shared-code + guestbook, per-project-redis.gitlab-ci.yml.tpl for the two redis apps). The CI templates are in their own project so any change flows to the other 3 via cross-project include:.

Architecture

Hold "Alt" / "Option" to enable pan & zoom
graph TB
  subgraph Manifest["apps_manifest.yaml — 4 entries"]
    SC["shared-code<br/>build_image=false, has_unit_tests=false"]
    GB["guestbook<br/>build_image=true, has_unit_tests=false"]
    R["redis<br/>build_image=false, redis_host=redis-master.redis-demo.svc.cluster.local"]
    RS["redis-slave<br/>build_image=false, redis_host=redis-replica.redis-slave-demo.svc.cluster.local"]
  end

  subgraph Canonical["apps/ — committed source"]
    SC_src["apps/shared-code/<br/>.gitlab-ci.yml (literal)<br/>+ templates/*.yml.tpl"]
    GB_src["apps/guestbook/<br/>guestbook-go/main.go<br/>guestbook-go/Dockerfile<br/>helm-chart/*"]
    R_src["apps/redis/<br/>helm-chart/*"]
    RS_src["apps/redis-slave/<br/>helm-chart/*"]
  end

  subgraph Rendered["apps-local/blueprint-apps/ — working tree (gitignored)"]
    SC_repo["shared-code repo<br/>+ literal .gitlab-ci.yml"]
    GB_repo["guestbook repo<br/>+ rendered .gitlab-ci.yml<br/>+ rsync'd apps/guestbook/*"]
    R_repo["redis repo<br/>+ rendered .gitlab-ci.yml<br/>+ rsync'd apps/redis/*"]
    RS_repo["redis-slave repo<br/>+ rendered .gitlab-ci.yml<br/>+ rsync'd apps/redis-slave/*"]
  end

  subgraph Pipeline["Per-project pipeline (kubernetes executor)"]
    Lint["lint stage<br/>helm lint + helm template"]
    Build["build stage (guestbook only)<br/>kaniko build → gitlab-registry.gitlab.svc:5000"]
    Deploy["deploy stage<br/>helm upgrade --install<br/>with CI_KUBECONFIG_B64"]
  end

  SC --> SC_src
  GB --> GB_src
  R --> R_src
  RS --> RS_src

  SC_src -. "manifest.shared-code first" .-> Rendered
  GB_src -. "rsync" .-> GB_repo
  R_src -. "rsync" .-> R_repo
  RS_src -. "rsync" .-> RS_repo

  SC_repo -. "include: {project: shared-code, ref: main, file: templates/per-project-app.gitlab-ci.yml}" .-> GB_repo
  SC_repo -. "include: shared-code/per-project-redis..." .-> R_repo
  SC_repo -. "include: shared-code/per-project-redis..." .-> RS_repo

  GB_repo --> Lint
  GB_repo --> Build
  GB_repo --> Deploy
  R_repo --> Lint
  R_repo --> Deploy

Key Directories

apps/shared-code/ — Cross-app CI templates

apps/shared-code/
├── .gitlab-ci.yml                              ← the literal CI file (no substitution)
├── templates/
│   ├── per-project-shared-code.gitlab-ci.yml   ← literal (used by shared-code project itself)
│   ├── per-project-app.gitlab-ci.yml.tpl       ← build_image=true (guestbook)
│   └── per-project-redis.gitlab-ci.yml.tpl     ← build_image=false (redis, redis-slave)
└── scripts/
    └── kaniko-buildah-wrapper.sh              ← used by per-project-app.gitlab-ci.yml

.gitlab-ci.yml is literal — no template substitution. It's the same thing pushed to GitLab as a real file. It defines the include chain:

# apps/shared-code/.gitlab-ci.yml (literal)
include:
  - local: 'templates/per-project-shared-code.gitlab-ci.yml'

The other 3 projects reference the shared-code project via cross-project include:

# per-project-app.gitlab-ci.yml.tpl (substituted as guestbook's .gitlab-ci.yml)
include:
  - project: 'blueprint-apps/shared-code'
    ref: main
    file: 'templates/per-project-app.gitlab-ci.yml.tpl'
    # After Phase 3 substitutes $placeholders, the above literally appears
    # as the project's CI definition. The substituted .yml.tpl ships
    # to apps-local/<name>/.gitlab-ci.yml, replacing the placeholder include.

Wait — the .yml.tpl itself is the one with the include: pointing at shared-code. After rendering, the pushed .gitlab-ci.yml in apps-local/blueprint-apps/guestbook/ is:

include:
  - project: 'blueprint-apps/shared-code'
    ref: main
    file: 'templates/per-project-app.yml.tpl'  # … but wait, the canonical file is unrendered

Actually no — looking at ci_render.py, the .tpl is the rendered form. The convention is:

  • apps/shared-code/templates/per-project-shared-code.gitlab-ci.yml (literal, used inside shared-code itself).
  • apps/shared-code/templates/per-project-app.gitlab-ci.yml.tpl is a template with $name/$build/$release/$namespaceplaceholders. Phase 3 reads it, substitutes, and writes the result toapps-local/blueprint-apps//.gitlab-ci.yml`.

The rendered file may cross-include other shared-code templates if needed.

apps/guestbook/ — Go app + Dockerfile + chart

apps/guestbook/
├── guestbook-go/
│   ├── main.go              ← classic k8s guestbook Go server
│   ├── go.mod
│   ├── go.sum
│   └── Dockerfile           ← multi-stage Go build → distroless/static
└── helm-chart/
    ├── Chart.yaml
    ├── values.yaml
    └── templates/
        ├── deployment.yaml
        ├── service.yaml
        ├── ingress.yaml     ← (unused; Envoy Gateway is the data-plane)
        └── ...

The Dockerfile is at guestbook-go/Dockerfile, not at the repo root — that's why the manifest entry sets dockerfile_path: guestbook-go/Dockerfile (relative to CI_PROJECT_DIR).

apps/redis/ and apps/redis-slave/ — Chart-only apps

apps/redis/
└── helm-chart/
    ├── Chart.yaml
    ├── values.yaml          ← image.repository=redis, image.tag=7.4.1
    └── templates/...

No Dockerfile — the chart references upstream redis:7.4.1. The CI pipeline skips the build stage (manifest has build_image: false).

Namespacing: the demo app uses helm_release: redis-master-demo + helm_namespace: redis-demo (and analogously for redis-slave). The infrastructure redis release (in redis namespace, set up by bootstrap --phase 2) backs GitLab and is untouched. Earlier versions used redis for both, which let the demo pipeline shadow the infra release and overwrite the StatefulSet + password (GitLab crashed). The new names put the demo app in redis-demo namespace.

The chart's templates create a Service literally named redis-master (no helm template), so the DNS is redis-master.redis-demo.svc.cluster.local. Matches the redis_host field in per-project-redis.gitlab-ci.yml.tpl which is interpolated as $namespace-redis-master.$namespace.svc.cluster.local.

CI Pipeline Anatomy

The shared-code CI template (per-project-app.gitlab-ci.yml.tpl) is structured as 3 stages:

stages:
  - validate
  - build
  - deploy

variables:
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  DOCKERFILE_PATH: guestbook-go/Dockerfile          # ← substituted per-project
  HELM_CHART_DIR: helm-chart
  HELM_RELEASE: guestbook                           # ← substituted
  HELM_NAMESPACE: guestbook                         # ← substituted
  HELM_VALUES_FILE: values.yaml

include:
  - 'templates/validate-job.yml'
  - 'templates/build-job.yml'
  - 'templates/deploy-job.yml'

The three jobs:

validate job

  • helm lint $HELM_CHART_DIR
  • helm template $HELM_RELEASE $HELM_CHART_DIR --set image.tag=$CI_COMMIT_SHA

Catches broken charts before build cost.

build job (guestbook only — build_image=true)

  • Uses kaniko (built into the Runner executor) to build guestbook-go/Dockerfile
  • context: $CI_PROJECT_DIR
  • dockerfile: $CI_PROJECT_DIR/$DOCKERFILE_PATH
  • Pushes to $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA (e.g. gitlab-registry.gitlab.svc:5000/blueprint-apps/guestbook:abc123)
  • Uses the kaniko-buildah-wrapper.sh script from shared-code/scripts/

For build_image=false (redis apps), this stage is omitted.

deploy job

  • Sets up the KUBECONFIG env var from CI_KUBECONFIG_B64 (a base64-encoded kubeconfig that was set as a CI variable by Phase 3).
  • helm upgrade --install $HELM_RELEASE $HELM_CHART_DIR --namespace $HELM_NAMESPACE --create-namespace --set image.tag=$CI_COMMIT_SHA --wait

Only runs on main (gated by rules: - if: $CI_COMMIT_BRANCH == "main").

The Runner's Kubernetes executor uses the gitlab-runner ServiceAccount — Phase 2 grants it namespace-create perm on the demo namespaces (guestbook, redis-demo, redis-slave-demo, shared-code).

CI Variables Set by Phase 3

For every project, blueprint-phase3 calls _gitlab.set_project_variables(...) with:

Variable Value Notes
CI_KUBECONFIG_B64 base64(infra/tofu/kubeconfig) Lets the CI job talk to the cluster via the same kubeconfig the host uses.
CI_REGISTRY_IMAGE <manifest.registry_image> e.g. gitlab-registry.gitlab.svc:5000/blueprint-apps/guestbook. kaniko + helm both read this.
CI_HELM_CHART_DIR <manifest.chart_path> e.g. helm-chart (for guestbook). Empty for shared-code.
CI_INSECURE_REGISTRY 1 The CI runner writes /etc/docker/daemon.json with insecure-registries for the in-cluster registry; without this, kaniko fails on x509.

The registry_image for guestbook is gitlab-registry.gitlab.svc:5000/blueprint-apps/guestbook — the in-cluster registry Service DNS, not the public hostname. The Runner resolves this via the cluster's CoreDNS, which is patched by phase2/coredns_patch.py to rewrite *.local.example.net to the Gateway ClusterIP. Same for kaniko pushing — the push goes to the Service on port 5000 (plain HTTP), not the Envoy Gateway on port 443.

App Internals

guestbook — guestbook-go/main.go

The classic Kubernetes guestbook: a Go server that lets you post messages and stores them in Redis. Originally from the k8s.gcr.io example in the k8s docs, lightly modernised for go.mod.

Key points:

  • Reads REDIS_HOST and REDIS_PORT from env vars (default redis-master:6379).
  • GET / returns index.html (in templates/).
  • POST / writes the message to Redis (LPUSH guestbook <msg>).
  • Uses gcr.io/distroless/static-debian12 as the runtime base — no shell in the image, minimal attack surface.

guestbook chart (apps/guestbook/helm-chart/)

A standard k8s chart:

# values.yaml (sketch)
image:
  repository: gitlab-registry.gitlab.svc:5000/blueprint-apps/guestbook
  tag: latest
  pullPolicy: Always

service:
  type: ClusterIP
  port: 80

replicaCount: 1

resources:
  requests:
    memory: 32Mi
    cpu: 50m
  limits:
    memory: 128Mi
    cpu: 200m

The chart doesn't define an Ingress (Envoy Gateway HTTPRoute is the data-plane, owned by the GitLab chart's Gateway controller — infra/scripts/bootstrap/phase2/references/helm-values-gitlab.yaml). The Service is ClusterIP; the Gateway routes <project>.local.example.net traffic to it.

redis chart (apps/redis/helm-chart/)

A single-pod redis (master only, no replicas). Uses values.yaml to pin:

image:
  repository: redis
  tag: 7.4.1
service:
  name: redis-master    # literal, must match redis_host in manifest
  port: 6379

The Demo workflow: guestbook writes to redis-master.redis-demo.svc.cluster.local:6379.

redis-slave chart (apps/redis-slave/helm-chart/)

A redis --replicaof <master> pod. Reads <master>.<master-namespace>.svc.cluster.local from values.yaml. Points at redis-master.redis-demo.svc.cluster.local (the demo master, NOT the infra redis-master.redis.svc).

Same Service naming trick: literal redis-replica to match redis_host in apps_manifest.yaml.

Conventions (from AGENTS.md, app-specific)

  • apps/ is the canonical, frozen source; apps-local/ is the runtime working tree. Edit code in apps/<name>/; the bootstrap rsyncs it into apps-local/.
  • Don't git push from apps/<name>/ directly — that bypasses the render + commit step. Always run blueprint-phase3.
  • The first project in apps_manifest.yaml is shared-code. Other projects' CI include: it.
  • Templates on disk, not in code. apps/shared-code/templates/*.yml.tpl is the only source of pipeline shape. ci_render.py does substitution. There's a regression test that fails on any yaml.dump literal in ci_render.py.
  • CI/CD variables set by blueprint-phase3. Don't glab variable set by hand (creates drift the next bootstrap run undoes).
  • Hand-editing .gitlab-ci.yml in apps-local/: use --no-overwrite-ci to keep your edits across bootstrap runs. Fold the change back into apps/shared-code/templates/ when stable, then drop the flag.
  • CI variables survive tofu destroy. The PAT re-mint in step 2 means every CI/CD variable is reproduced on the next install.

Smoke Tests

Per .agents/skills/provision-phase-3/SKILL.md §3 Smoke tests:

Check How to verify
shared-code repo exists glab api groups/blueprint-apps/projects/$(glab project list -s shared-code -o json \| jq -r .[0].id)
guestbook pipeline green glab api projects/<guestbook-id>/pipelines?per_page=1 → status success
guestbook image in registry glab api projects/<guestbook-id>/registry/repositories/<repo-id>/tags?per_page=5
guestbook reachable curl https://guestbook.local.example.net → 200
redis pod running kubectl -n redis-demo get podsredis-master-demo-0 Running
guestbook → redis chain works curl -X POST https://guestbook.local.example.net -d "msg=hello" then redis-cli LRANGE guestbook 0 -1 shows it

Potential Improvements

  • A real pytest suite for the Go app (currently has_unit_tests: false).
  • A multi-stage Dockerfile that's faster + uses BuildKit cache mounts.
  • Auto-generated helm-docs README per chart (manual currently).
  • Per-project PR pipelines (run validation only, skip deploy) — currently every push to main does the full pipeline.
  • A separate staging environment (mirror cluster) so main doesn't auto-deploy to prod-equivalent.