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¶
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.tplis 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_DIRhelm 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_DIRdockerfile: $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.shscript fromshared-code/scripts/
For build_image=false (redis apps), this stage is omitted.
deploy job¶
- Sets up the
KUBECONFIGenv var fromCI_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_HOSTandREDIS_PORTfrom env vars (defaultredis-master:6379). GET /returnsindex.html(intemplates/).POST /writes the message to Redis (LPUSH guestbook <msg>).- Uses
gcr.io/distroless/static-debian12as 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 inapps/<name>/; the bootstrap rsyncs it intoapps-local/.- Don't
git pushfromapps/<name>/directly — that bypasses the render + commit step. Always runblueprint-phase3. - The first project in
apps_manifest.yamlisshared-code. Other projects' CIinclude:it. - Templates on disk, not in code.
apps/shared-code/templates/*.yml.tplis the only source of pipeline shape.ci_render.pydoes substitution. There's a regression test that fails on anyyaml.dumpliteral inci_render.py. - CI/CD variables set by
blueprint-phase3. Don'tglab variable setby hand (creates drift the next bootstrap run undoes). - Hand-editing
.gitlab-ci.ymlinapps-local/: use--no-overwrite-cito keep your edits across bootstrap runs. Fold the change back intoapps/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 pods → redis-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
Dockerfilethat's faster + uses BuildKit cache mounts. - Auto-generated
helm-docsREADME per chart (manual currently). - Per-project PR pipelines (run validation only, skip deploy) — currently every push to
maindoes the full pipeline. - A separate
stagingenvironment (mirror cluster) somaindoesn't auto-deploy to prod-equivalent.