ADR-017: The GitOps repo owns every Kubernetes manifest; services ship an image
Status: Accepted
Date: 2026-10-07
Supersedes (in part): ADR-014 (remote Kustomize bases, per-service k8s/base), ADR-006 overlays-per-service-repo assumption, ADR-012 deploy-check
Context
Section titled “Context”The end-to-end todo-app test (platform v1.1.1) found that the Kubernetes files inside each service repo were the main source of defects: an invalid team: "@owner" label that made every Deployment unappliable, API_URL wiring hard-coded in the web repo (a product concern, not a service concern), replicas wrong for an in-memory app, k8s/base/deployment.yaml overwritten by skeleton updates, non-numeric container users, kustomize ?ref= versus image-tag confusion (sha-<short> is not a git ref; semver images have no v), and private-repo credentials needed by Argo and by CI just to kustomize build another repo. Service-level overlays/{local,staging,prod}, k3d, kubectl, k9s and deploy/deploy-check were unused once a gitops-app repo existed.
Decision
Section titled “Decision”- Services ship only a container image. The four service templates (
service-python,service-java,service-go,web-nextjs) contain nok8s/directory and no Kubernetes tooling;shapes.py checkfails if one reappears. - The gitops-app repo generates all manifests from
applications/<app>/services.yaml(+app.yaml) withscripts/render.py:deployment.yaml,service.yaml, optionalhttproute.yaml, and akustomization.yamlthat pins the image tag. No remote bases, no?ref=; Argo reads only the gitops repo. shapes.ymlcarriesruntime:defaults per deployable shape (port, probe paths, numeric user, writable volumes, env, resources)./gitops:compose addcopies them into a complete, explicitservices.yamlentry (the service’s own.copier-answers.ymlportwins), so every value is reviewable and the generator stays shape-agnostic.- Wiring is product configuration:
env(e.g.API_URL: http://todo-api),replicasandresourcesper environment,secretRefs(names only),expose. - Environments are opt-in: a service is rendered only for the environments in
environments(default[dev]);/gitops:promoteaddsstaging, thenprod. A new service no longer reaches prod on day one. - Promotion pins an image tag only: staging
sha-<first 7 of the commit>of a main build, prodX.Y.Z(thevX.Y.Zgit tag withoutv, exactly what CI publishes); both verified to exist in GHCR before the PR is opened. - Exposure uses the Gateway API:
expose:renders anHTTPRouteattached to the gateway named inapp.yaml; the hostname comes from a per-environment template (dev default{service}.{app}-dev.localhost;*.localhostresolves to loopback everywhere, unlikelocaltest.me/nip.io which a rebind-protecting DNS blocks).cluster-upenables Traefik’s Gateway provider on k3s (the Gateway API CRDs already ship with k3s’traefik-crdchart — installing them ourselves races with that Helm install and breaks a fresh cluster, found by the e2e test;HelmChartConfig:providers.kubernetesGateway.enabled,gateway.listeners.web.namespacePolicy: All) — verified on k3s v1.32.5 / Traefik 3.3.6. Real clusters pointgateway:at their own controller. - The Deployment selector stays
app: <name>(what v1 used), so migrating a running v1 Deployment is an in-place update, not a recreate.
Consequences
Section titled “Consequences”- Breaking → platform v2. Migration: for each service
/gitops:compose add <svc> --from-k8s(seeds the entry from the service’sk8s/base), then/shared:update-service --migratein the service repo (removesk8s/), thendevbox run render/ PR in the gitops repo. Existing v1services.yamlentries (withpath:) failrender.pywith an actionable error. - Gone:
SERVICE_REPOS_TOKEN, Argo credentials for service repos,deploy/deploy-checkrecipes,owner_label, per-service PrometheusRule files (alerting moves to a gitops-side add-on, tracked as a follow-up). The platform-wide “ApplicationSet discovers service repos by topic” mode and itspromotevariant only apply to v1 services. - Trade-off: changing a probe path or port is now a two-repo change (Dockerfile/app +
services.yaml); the deployment agents say so explicitly. A service-side descriptor can be added later if this hurts. - Generated manifests are validated offline in CI (
check-k8s-labels.py,kubectl kustomize, kubeconform with Argo/Gateway CRD schemas) and server-side on a real cluster in the e2e job.