Skip to content

feat(hook): order manifest keys to match hand-authored convention - #19

Closed
neilverc wants to merge 1 commit into
mainfrom
move-initcontainers-before-containers
Closed

feat(hook): order manifest keys to match hand-authored convention#19
neilverc wants to merge 1 commit into
mainfrom
move-initcontainers-before-containers

Conversation

@neilverc

Copy link
Copy Markdown
Collaborator

Why

ctx.std.yaml.stringify() is the sole YAML serialization path every kind's hooks funnel through (parse → mutate → stringify), and its output is written to disk byte-for-byte with no further re-marshal. Until now it went through plain yaml.v3 map[string]any encoding, which always alphabetizes keys — e.g. containers sorts ahead of initContainers even though init containers run first.

What

  • orderedYAMLMarshal builds a *yaml.Node tree by hand (preserving explicit key order, unlike map[string]any) and applies keyOrderGroups on top of the default alphabetical sort.
  • Each group is an ordered list of keys. applyOrderGroup gathers whichever of a group's keys are present into a contiguous block, in the group's order, and splices that block in at the position where the earliest one already sits — every other key, including one matched by only a single key, keeps its current position untouched.
  • Groups are path-agnostic: they fire on sibling key names wherever they co-occur, so e.g. the PodSpec group covers a Deployment's spec.template.spec, a CronJob's nested spec.jobTemplate.spec.template.spec, a bare Job, DaemonSet, StatefulSet, etc. with no per-kind knowledge.

How the groups were chosen

Groups and their order are chosen to minimize reordering churn against the ~450 hand-maintained services/*/_infra/*.yaml files in the api monorepo (measured via a structural YAML walk classified by shape), not to reproduce the Kubernetes API's declared struct order — the two disagree about as often as they agree. Several evidence-backed candidates (Container's name/image/etc, DeploymentSpec, CronJobSpec, Probe) were tested and dropped because every ordering of their keys still cost 54-100% churn: those keys are routinely interleaved with untouched sibling fields in real files, with no consistent convention to anchor a fixed order against.

The five groups kept all measured under ~15% churn, except ObjectMeta's pod-template case (a genuine ~68/32 split with no fully resolving order) and JobSpec (kept anyway — tiny blast radius):

Group Order Churn
PodSpec initContainers, containers ~17%
ObjectMeta annotations, labels, name, namespace ~5% top-level / ~32% pod-template
VolumeMount mountPath, name, readOnly, subPath ~1%
ResourceRequirements limits, requests, claims ~1%
JobSpec parallelism, completions, selector, template ~11% (36 instances total)

volumes was deliberately left out of the PodSpec group: it's declared right before initContainers/containers in k8s.io/api's struct order, but in practice sits far from them (near restartPolicy/serviceAccountName) in real files, so including it dragged the whole block across everything in between on nearly every file (99% churn either way).

Validation

go build ./..., go vet ./..., gofmt -l . clean. go test ./... passes across every package, including new unit tests for applyOrderGroup (no-op on zero/one match, splice-in-place semantics, doesn't drag a distant match forward) and integration tests through ctx.std.yaml.stringify() covering each kept group plus path-agnostic nesting (CronJob's pod spec).

ctx.std.yaml.stringify() is the sole YAML serialization path every kind's
hooks funnel through (parse -> mutate -> stringify), and its output is
written to disk byte-for-byte with no further re-marshal. Until now it
went through plain yaml.v3 map[string]any encoding, which always
alphabetizes keys — e.g. "containers" sorts ahead of "initContainers"
even though init containers run first.

Add orderedYAMLMarshal: builds a *yaml.Node tree by hand (preserving
explicit key order, unlike map[string]any) and applies keyOrderGroups on
top of the default alphabetical sort. Each group is an ordered list of
keys; applyOrderGroup gathers whichever of a group's keys are present
into a contiguous block, in the group's order, and splices that block in
at the position where the earliest one already sits — every other key,
including a group matched by only one key, keeps its current position
untouched. Groups are path-agnostic: they fire on sibling key names
wherever they co-occur, so e.g. the PodSpec group covers a Deployment's
spec.template.spec, a CronJob's nested spec.jobTemplate.spec.template.spec,
a bare Job, DaemonSet, StatefulSet, etc. with no per-kind knowledge.

Groups and their order are chosen to minimize reordering churn against
the ~450 hand-maintained services/*/_infra/*.yaml files in the api
monorepo (measured via a structural YAML walk classified by shape), not
to reproduce the Kubernetes API's declared struct order — the two
disagree about as often as they agree, and several evidence-backed
candidates (Container's name/image/etc, DeploymentSpec, CronJobSpec,
Probe) were tested and dropped because every ordering of their keys
still cost 54-100% churn: those keys are routinely interleaved with
untouched sibling fields in real files, with no consistent convention to
anchor a fixed order against, so any fixed order disturbs most of them
regardless. The five groups kept all measured under ~15% churn (except
ObjectMeta's pod-template case, a genuine ~68/32 split with no fully
resolving order, and JobSpec, kept anyway for its tiny blast radius):

  - PodSpec: initContainers before containers (~17% churn). volumes is
    declared right before them in k8s.io/api's struct order but sits far
    from them in real files, so including it was dropped (99% churn).
  - ObjectMeta: annotations, labels, name, namespace — one list serves
    both the top-level metadata convention (labels-first, ~5%) and the
    pod-template metadata convention (annotations-first, ~32%) since the
    two contexts have almost disjoint typical keysets.
  - VolumeMount: mountPath, name, readOnly, subPath (~1%; the declared
    name-first order costs ~100%).
  - ResourceRequirements: limits, requests, claims (~1%; already agrees
    with the declared order).
  - JobSpec (CronJob's embedded job template): parallelism, completions,
    selector, template (~11%, 36 instances total).
@neilverc
neilverc marked this pull request as ready for review August 14, 2026 15:47
@neilverc

Copy link
Copy Markdown
Collaborator Author

can push inside api repo directly

@neilverc neilverc closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant