DevOps · Platform Automation

Template Ownership When Scaffolding a New Service

A generator produces a fork of the template the moment it runs. What that implies for update paths, three-way merges, and the split between generated and referenced configuration.

A service template is a promise that a new service starts in a known-good state: build file, container definition, deployment manifests, health endpoint, ownership metadata, and the policy annotations the platform requires. Rendering it takes seconds. Keeping the result in that state for two years is the harder problem, and it is not a templating problem at all. It is an ownership problem, because the output of a generator is a copy, and a copy diverges.

The divergence is not a discipline failure. The template changes because the platform changes: a base image moves, a probe path changes, a required label is added for cost attribution. Every repository rendered from the old template keeps the old values, and the platform has no way to tell which of those values are stale and which were deliberately overridden.

Rendering is a fork with a receipt

A one-shot generator writes files into a repository and stops. From that point the repository is an independent codebase that happens to resemble the template. Nothing records which template version produced it, which inputs were supplied, or which files the generator considered its own. Without that record, an update is impossible to automate and difficult to do by hand: the updater cannot distinguish “this file differs because the tenant edited it” from “this file differs because the template changed”.

The record itself is small — template identity, template version, and the answers to the generator’s questions. Storing it in the repository, in a file the tenant does not edit, is what converts a fork into a tracked fork. Some generators write it automatically; the ones that do not force the platform to invent a convention, and the convention is only useful if every generated repository has it from the first commit.

The receipt also answers the audit question. When a repository is found running an image the platform retired a year ago, the receipt says which template version produced the file and therefore whether the repository was never updated or was updated and then edited back.

Three mechanisms for keeping generated code current

The available designs differ in who owns the file after generation.

Re-render and three-way merge. The updater renders the template twice: once with the recorded answers at the recorded version, and once with the same answers at the new version. It then merges the difference between those two renders into the working tree, with the previous render as the merge base. A tenant edit that does not touch a changed line survives; an edit that touches a changed line becomes a conflict the tenant resolves.

# base = previous render, other = new render, current = the tenant's tree
git merge-file -p service.yaml old-render/service.yaml new-render/service.yaml > merged.yaml

This is the mechanism copier update implements, and it is the only one that preserves local modifications while still delivering upstream changes. Its cost is that the merge is textual. A template that reorders keys or reformats YAML produces conflicts on lines nobody changed semantically, and a tenant who resolves twenty such conflicts by hand learns to skip updates.

Regenerate and overwrite. The updater renders the new version and replaces the generated files wholesale. This is correct only if the generated files are genuinely not the tenant’s — which requires the template to place every file the tenant may edit outside the generated set. Where that boundary holds, overwriting is simple and has no conflicts. Where it does not, the first update destroys work.

Reference instead of copy. Nothing is generated. The tenant’s repository contains a thin file that references a shared module, chart, or pipeline definition, and the platform’s changes reach every tenant when the reference resolves to a new version. Nothing diverges because nothing was copied. The cost moves to the interface: the shared artifact must be parameterised enough to cover every tenant’s legitimate variation, and its version must be pinned per tenant or every change is a fleet-wide event.

The three are not exclusive. A common arrangement generates the small, tenant-specific parts — service name, port, resource sizing — and references a shared chart for everything the platform owns. The generator then has very little to update, and the reference carries the churn.

Drawing the boundary between generated and referenced

The useful test for a file is whether its content encodes a decision the tenant must own. Resource names, ports, replica counts, and business logic are tenant decisions. Probe paths, security context, log format, sidecar injection, and the label set used for cost allocation are platform decisions. The first group belongs in the repository as editable text; the second belongs behind a reference the tenant does not edit, or in a generated file clearly marked as generated.

Two failure modes follow from getting this wrong in each direction.

Generating too much produces repositories that cannot be updated, because every platform change collides with a tenant edit. Generating too little produces repositories that are technically compliant at creation and silently non-compliant a month later, because the platform’s requirement lives in a file the tenant may edit and the tenant edited it.

There is a third case worth naming: a file that is generated, marked as generated, and edited anyway. The marker is a social signal, not an enforcement mechanism. If the platform needs the file to be correct, it must either re-render it on every sync — which is what the reconciliation loop in level-triggered reconciliation provides — or reject the edit at admission.

Questions the generator must answer before it writes anything

Three inputs are easy to omit and expensive to add later.

Identity. The service needs a name that is unique across the platform, not merely within its repository. The name becomes a DNS label, a cloud resource name, a metrics label, and a policy subject. Renaming later means changing all four, and the generator is the only moment when the name is free.

Ownership. The generated metadata should record the team, the contact, and the repository, in the form the platform’s inventory reads. A template that omits it produces services that exist and are unowned, which is the state every cost and incident process handles badly.

Deletion semantics. Whether the provisioned resources survive the deletion of the repository is a decision, not a default. The template should encode it explicitly, because the two answers have very different consequences and the tenant will assume whichever one they expected.

The generator also decides what it will refuse. A template that accepts an empty service name, or a port outside the valid range, produces a repository that fails at the first build. Validating the answers before rendering is cheaper than debugging the output, and the validation rules are the same ones the platform enforces at admission, expressed once.

Where the golden path diverges from the supported path

The most damaging failure of a self-service template is not a bug in the render. It is a template that produces a configuration the platform does not actually support. The template was written against an older runtime, or against a variant that one team uses, and the generated repository passes its own tests while failing in the platform’s environment in ways the tenant cannot diagnose.

The mechanism that prevents this is to make the template’s output pass through the same validation as a hand-written repository: render the template in the platform’s own CI, apply the result to a scratch namespace, and run the same policy checks that admission will run. A template is a program, and the only way to know its output is valid is to execute it against the real constraints. This is the same argument that makes policy bundle testing necessary before a rule gates writes, applied to the artifact the rule will judge.

The second-order problem is version skew in the other direction. A tenant on an old template version is not merely missing improvements; they may be running a configuration the platform has stopped supporting, and the platform’s policy has no way to distinguish them from a tenant who chose the old behaviour deliberately. Recording the template version in the repository is what makes that distinction possible, and it is the same requirement that appears when the platform’s own interface has to change under existing tenants, as described in versioning the platform interface.

References

  1. Copier Documentation — Copier project
  2. Backstage Documentation: Software Templates — Backstage project
  3. Git Documentation: git-merge-file — Git project