The app
Mandelbrot checkpoint #
The GitOps-managed hello service proved the delivery path. It showed that each cluster could run a pod from Git, keep it reconciled through Argo CD, and expose it through a public LoadBalancer. That was enough for the GitOps checkpoint.
A platform needs a workload that can show more than "nginx is alive." It needs health checks for traffic routing, a visible browser experience, enough CPU work to make metrics interesting, and a shape that can later support cross-cluster behavior without introducing a database problem.
The next workload is a small Mandelbrot renderer.
That choice is deliberately practical. Mandelbrot rendering is stateless, easy to understand visually, and naturally divisible into independent tiles. A single request can become three pieces of work, one for AWS, one for GCP, and one for Azure. If one piece fails, the UI can show that failure without needing a shared datastore or a complicated business domain.
For this checkpoint, though, the goal is smaller:
- keep the
helloproof while adding a real application - deploy the same application to all three clusters
- give each cloud a stable public origin
- keep the application portable across clouds
- leave enough hooks for traffic, observability, traces, and rollouts later
Application shape #
The app is intentionally compact: one Node.js HTTP server and one browser UI. There is no database, queue, cache, or object store. The service exposes a small set of endpoints:
GET /healthz liveness check
GET /readyz readiness check
GET /metrics Prometheus-style metrics
GET / browser UI
GET /api/meta runtime identity
POST /api/render render request
POST /internal/render-stage per-cloud stage render
In the server, those endpoints stay deliberately boring:
if (req.method === "GET" && url.pathname === "/healthz") {
jsonResponse(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/readyz") {
jsonResponse(res, failReady ? 503 : 200, { ok: !failReady, cloud, region });
return;
}
if (req.method === "GET" && url.pathname === "/api/meta") {
jsonResponse(res, 200, {
cloud,
region,
pod: os.hostname(),
route: stageRoute,
});
return;
}
The browser sends render requests to /api/render. The server decides which cloud stages should render which tile ranges, calls those stages, and returns the ordered tiles to the browser.
At this point the stage calls are allowed to collapse back to local rendering. That fallback matters during bootstrap. The app should be useful before the global traffic stack exists and before each cluster knows the public URLs for the other two clusters.
The source is also packaged in the simplest possible way for this phase. The pod runs the official Node.js image, and Kustomize puts the server and browser files into a ConfigMap:
apps/mandelbrot/base/
app/
index.html
server.js
namespace.yaml
deployment.yaml
service.yaml
kustomization.yaml
The Kustomize base generates that ConfigMap directly from the source files:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
- name: mandelbrot-source
files:
- index.html=app/index.html
- server.js=app/server.js
- name: mandelbrot-stage-urls
literals:
- AWS_STAGE_URL=
- GCP_STAGE_URL=
- AZURE_STAGE_URL=
That is different from how I would package a production service. A production version should build an immutable image and promote that image through environments. For this exercise checkpoint, the ConfigMap keeps the application source inside the GitOps path and avoids adding image registry mechanics before the platform delivery model is settled.
The base Kustomize package generates the source ConfigMap and defines the shared workload. Each cloud gets the same base workload with a small overlay:
apps/mandelbrot/
base/
app/
namespace.yaml
deployment.yaml
service.yaml
kustomization.yaml
overlays/
aws/
gcp/
azure/
The overlays only set the cloud and region identity:
aws -> us-east-1
gcp -> us-central1
azure -> eastus
The AWS overlay is typical. It patches the base workload with the cloud identity for that cluster:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
configMapGenerator:
- name: mandelbrot-stage-urls
behavior: merge
literals:
- AWS_STAGE_URL=
- GCP_STAGE_URL=
- AZURE_STAGE_URL=
patches:
- target:
kind: Deployment
name: mandelbrot
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/value
value: aws
- op: replace
path: /spec/template/spec/containers/0/env/1/value
value: us-east-1
That identity appears in /api/meta, health responses, metrics, and the browser UI. It is small, but it is the first useful signal that the same application is running independently in three different places.
Existing GitOps path #
Argo CD is already in the platform from the previous checkpoint. Pulumi creates the clusters and bootstraps one root Argo CD application per cloud; Argo CD then reconciles the Kubernetes application state from Git.
This checkpoint uses that existing path instead of introducing a second delivery mechanism. The change is the workload: the hello application remains a simple proof, and Mandelbrot becomes the first application with meaningful runtime behavior.
GitOps ownership #
Argo CD owns one Mandelbrot application per cloud:
trinity-mandelbrot-aws
trinity-mandelbrot-gcp
trinity-mandelbrot-azure
Each Argo CD application points at the matching Kustomize overlay:
apps/mandelbrot/overlays/aws
apps/mandelbrot/overlays/gcp
apps/mandelbrot/overlays/azure
The Argo CD application is the object that connects those two things: the Git path and the local cluster destination. The AWS application looks like this:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: trinity-mandelbrot-aws
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
labels:
app.kubernetes.io/part-of: trinity
trinity.io/cloud: aws
spec:
project: trinity
source:
repoURL: https://github.com/maxgherman/trinity.git
targetRevision: main
path: apps/mandelbrot/overlays/aws
destination:
server: https://kubernetes.default.svc
namespace: mandelbrot
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
The ownership boundary is slightly different from hello.
For a disposable hello-style workload, one tool can own the namespace, deployment, and service. The service is just a proof that the cloud load balancer path works.
For this checkpoint, Argo CD owns the Mandelbrot Kubernetes objects:
Argo CD:
- mandelbrot namespace
- Mandelbrot workload
- public LoadBalancer service
- application source ConfigMap
- empty stage URL ConfigMap
- cloud-specific Kustomize overlays
That is still a clean boundary. Pulumi creates the clusters and installs Argo CD. Argo CD reconciles the app. The public service is a plain Kubernetes LoadBalancer with no static address binding yet:
apiVersion: v1
kind: Service
metadata:
name: mandelbrot
namespace: mandelbrot
labels:
app.kubernetes.io/name: mandelbrot
app.kubernetes.io/part-of: trinity
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: mandelbrot
ports:
- name: http
port: 80
targetPort: http
The selector is the contract inside the app package. The service selects pods with app.kubernetes.io/name: mandelbrot, and the deployment supplies that label.
Each cluster now creates a public origin for Mandelbrot:
AWS -> cloud-assigned LoadBalancer endpoint
GCP -> cloud-assigned LoadBalancer endpoint
Azure -> cloud-assigned LoadBalancer endpoint
Those addresses are enough to validate the app from each cloud. They are not yet good enough for the global traffic layer, because generated load balancer addresses are awkward infrastructure dependencies. The next phase will tighten that boundary by moving the public service and static address bindings into Pulumi.
Bootstrap behavior #
The renderer already knows the future route:
aws,gcp,azure
Eventually a render request should fan out across all three clouds. The app in AWS should be able to call the GCP and Azure stage URLs, the app in GCP should be able to call AWS and Azure, and so on.
The app cannot require those URLs during the first deployment. Otherwise the Mandelbrot workload would depend on a traffic layer that does not exist yet. That would make the bootstrap order unnecessarily fragile.
Instead, the stage URL ConfigMap starts with empty values:
configMapGenerator:
- name: mandelbrot-stage-urls
literals:
- AWS_STAGE_URL=
- GCP_STAGE_URL=
- AZURE_STAGE_URL=
The deployment reads those values as ordinary environment variables:
env:
- name: STAGE_ROUTE
value: aws,gcp,azure
- name: AWS_STAGE_URL
valueFrom:
configMapKeyRef:
name: mandelbrot-stage-urls
key: AWS_STAGE_URL
- name: GCP_STAGE_URL
valueFrom:
configMapKeyRef:
name: mandelbrot-stage-urls
key: GCP_STAGE_URL
- name: AZURE_STAGE_URL
valueFrom:
configMapKeyRef:
name: mandelbrot-stage-urls
key: AZURE_STAGE_URL
When a remote stage URL is empty, the server renders that stage locally. That keeps the app useful before the traffic phase has a way to write real cross-cluster stage URLs.
That is the useful compromise for this checkpoint: the app can stand alone in each cluster, but it is already shaped for the multi-cloud demo.
Validation #
The validation is the same in each cluster:
KUBECONFIG=./kubeconfig.aws.yaml kubectl -n mandelbrot get deployment,service,pods
KUBECONFIG=./kubeconfig.gcp.yaml kubectl -n mandelbrot get deployment,service,pods
KUBECONFIG=./kubeconfig.azure.yaml kubectl -n mandelbrot get deployment,service,pods
Then the public service endpoint should return the application HTML and the health endpoint should return success:
curl "http://<mandelbrot-cloud-endpoint>/readyz"
curl "http://<mandelbrot-cloud-endpoint>/"
The readiness response should identify the cloud and region. The browser UI should load from each direct origin. The application list in Argo CD should show the three Mandelbrot applications as synced and healthy.
All three clusters now pass that check.
That means the platform has moved past "can I create three clusters?" and into the more useful state:
- GitHub Actions can create or update the cloud stacks.
- Argo CD can reconcile a non-trivial app into EKS, GKE, and AKS.
- The app can be reached directly through each cloud's load balancer.
- The app already has health, identity, metrics, and render endpoints for later phases.
The next phase can stop treating those load balancers as three separate user entry points. The traffic layer can create stable origins, put one public HTTPS endpoint in front of them, and introduce dynamic cross-cluster stage configuration.