| name | migration-tool |
| description | Authoring and verifying the migration tool — the engine split, the mapping schema, row ordering and cell conventions, the Node verification recipe and its silent-failure gotcha, and the four-point accuracy check. Use when editing annotation mappings, CRD generators or the reference tables. |
The migration tool
ingress-nginx-migration.html is live at https://kubernetes.nginx.org/ingress-nginx-migration.html — an interactive YAML analyzer with 130 annotation mappings across 57 mapping objects, CRD migration examples and ConfigMap guidance.
Engine
The page runs on a source-agnostic engine:
assets/js/migration-ingress-nginx.js — the source module. Supplies INGRESS_NGINX_VERSION, ANNOTATION_MAPPINGS, parsers, CRD generators and sample presets; defines window.MIGRATION_SOURCE. Never touches the DOM, and may dereference MigrationTool.* only inside function bodies (call time), never at top level.
assets/js/migration-core.js — the core. Owns analyzer orchestration and rendering, table filtering, page nav and the checklist; defines window.MigrationTool (NIC target versions plus shared utils).
Load order is shared.js → migration-<source>.js → migration-core.js, and the source must precede the core because the core reads window.MIGRATION_SOURCE at top level.
The page is linked from the landing page with a relative path (href="ingress-nginx-migration.html") so it resolves identically from the filesystem, from a local server and in production. Do not change it to an absolute FQDN — that only works in production and breaks local testing. (check-classes.py fails on an absolute asset path.)
Navigating the page without reading it
ingress-nginx-migration.html is 4,952 lines / ~92k tokens. Reading it whole
costs a large fraction of a context window before you have made a single edit,
and leaves nothing for the 1,340-line source module you must edit alongside it.
Do not read it whole.
grep -n '<section id=' ingress-nginx-migration.html
grep -n '<h3 id=' ingress-nginx-migration.html
.github/data/mapping-index.json — generated by test-analyzer.js, so it cannot
drift — maps every annotation to its category, anchor, type, CRD kind and
generator. Read it first; it answers "where is this documented and does it emit a
CRD?" in one small file.
Finding a specific annotation's row. Match the annotation name followed by
<, not the start of a cell:
grep -n 'nginx\.ingress\.kubernetes\.io/proxy-body-size<' ingress-nginx-migration.html
The obvious pattern — <td><code>nginx.ingress.kubernetes.io/NAME — only finds
the 60 rows whose first annotation it is. Grouped rows are
<td><code>A</code><br><code>B</code></td>, so any annotation but the first is
invisible to it: session-cookie-name and auth-tls-verify-depth both return
zero hits that way and one hit the right way.
Anchors. <section id> is canonical for the twelve top-level sections —
migration-core.js rewrites h2 permalinks to the section id so they agree with
sidebar navigation. Category anchors are the 49 <h3 id> values, and
check-classes.py asserts that every analyzer category: string equals its
<h3> text.
Adding an annotation mapping
The most common edit. A mapping object in ANNOTATION_MAPPINGS:
{ community: ["proxy-body-size"],
nic: "nginx.org/client-max-body-size — or — VirtualServer CRD upstreams[].client-max-body-size",
type: "annotation", category: "Buffering", anchor: "buffering",
section: "oss", dualApproach: false, plusRequired: false,
nicMapping: { annotations: { "proxy-body-size": { key: "nginx.org/client-max-body-size",
transform: "direct" } } } },
| Field | Meaning |
|---|
community | source annotation names, alphabetical, without the prefix |
nic | the display string shown in the right-hand column |
type | annotation, policy, configmap, unsupported — drives which plan step it lands in |
category | must equal the <h3> text verbatim, sentence case — asserted by check-classes.py |
anchor | the <h3 id> this row lives under |
section | oss or plus, selecting the reference table |
nicMapping.annotations | keyed by community annotation name, each with a target key and a transform |
nicMapping.crdKind + templateFn | for a mapping that emits a resource; the generator name must be a real function |
transform is nested inside each annotation entry, not a top-level field — a
misspelling there falls through translateValue's default: return value and
emits the raw value. That is now asserted; it was not, and all ten
snippetWrap occurrences could be corrupted with the suite still green.
Edit in this order, because each step's check depends on the last:
- the mapping in
assets/js/migration-ingress-nginx.js
- its generator, if it declares
templateFn
- the reference-table row in the HTML
- the example YAML in that row's expanded panel — generated output is the
truth, the example follows it
python3 .github/scripts/check-all.py
test-analyzer.js asserts steps 1–3 agree mechanically: every templateFn has a
generator and vice versa, every transform has a translateValue case, and
every mapped annotation has a documented row and vice versa. It cannot see
step 4 — a hand-written example drifting from a still-correct generator is
row-level invisible, and remains the recurring bug here.
Storage keys are frozen: the checklist persists against data-id values in
localStorage, so renaming one silently resets every reader's progress.
Why the reference tables stay hand-authored HTML
Considered and rejected: rendering them from the mapping data at load.
- Only 19 of the mappings have a generator to derive an example from; the rest of
each row is prose that exists nowhere else, so most of the markup would have to
move into the data rather than disappear.
- The page ships a
<noscript> fallback, print overrides in
migration.css, and hidden="until-found" panes so in-page find still reaches
inactive views. All three depend on the content being in the served HTML.
- Readers ctrl-F this page. Client-rendered rows are not in the document a search
engine or a browser find sees on load.
The sync problem it would have solved is instead solved by assertion:
test-analyzer.js fails when the mappings and the tables disagree in either
direction. That covers rows; it does not cover example YAML, which is the
residual risk accepted here.
Mappings and reference tables must agree in both directions
This is the rule that generates the most bugs.
- Editing a mapping or its generator means updating the matching reference-table row — including the example YAML in the expanded panel, which must match what the corresponding generator emits.
- Editing a reference row whose construct the analyzer handles (there is a matching
ANNOTATION_MAPPINGS entry) means updating the mapping or generator to match.
A recurring failure is a hand-written example drifting from its still-correct generator. Treat the generator as the source of truth and fix the example to match it.
Exception: reference rows for NIC-only features with no community equivalent — the left cell reads "No direct equivalent" — have no analyzer counterpart, so editing them needs no JS change. The apiKey Policy row is one, since the community controller has no API-key annotation for the analyzer to map.
Verifying analyzer changes
There is no build system, but there is a test suite. .github/scripts/test-analyzer.js loads shared.js + the source module + the core under a hand-rolled window/document stub and runs every sample preset through both strategies, asserting the frozen output shape in its EXPECTED table. .github/test/ holds the node:test wiring suites.
The harness is hardcoded to the ingress-nginx module, which is why a branch shipping a second source module writes its own .github/test/ suite rather than extending it.
Two techniques worth knowing beyond running it:
- Golden diff. For a refactor that should change nothing, dump the full plan JSON for every preset before and after and compare. Byte-identical output is a far stronger claim than "the counts still match", and it is how
03455b0 was verified.
- Fault injection. Before trusting a new assertion, break the thing it claims to catch and confirm it reports it. This repo has six recorded instances of a check that could not fail; the only defence is planting the failure.
Load-bearing gotcha: buildPlan runs each generator in a try/catch that only console.warns on failure, so a broken generator silently drops its resource from the output instead of throwing. Capturing console.warn — count > 0, not a thrown exception — is the only way to detect it. node --check catches syntax only.
Also sanity-check generated k8s.nginx.org/v1 field names against the json: tags in nginx/kubernetes-ingress/pkg/apis/configuration/v1/types.go to catch invalid CRD fields.
Which section a row goes in
A source construct is documented once, in exactly one reference section, and which one is not a judgement call:
- NIC tier first. If the NIC equivalent requires NGINX Plus, the row goes in
#plus-mappings — whatever surface it came from. An annotation, a ConfigMap key and a CRD field whose only NIC answer is Plus all land there.
- Then the source surface. Annotations →
#mappings. ConfigMap keys → #configmap-mappings. CR/CRD fields → #crd-mappings. Controller flags → #flag-mappings.
Two corollaries, both load-bearing:
- A row placed outside the section its surface implies must name that surface in its left cell —
timeout-queue ConfigMap key, ConfigMap hsts, check / httpchk_params (Backend CRD). The section heading is otherwise the reader's only clue what a bare key is, and a ConfigMap-only key sitting unlabelled in the annotation tables tells them an annotation exists that does not. This is what makes a deliberate cross-listing legal and an accidental one a defect.
- The category the row leaves carries an "Elsewhere" note pointing at the new home, because a reader looks a key up where its siblings are.
haproxy.org/check stays in Health checks and its note points at Active health checks.
Emptying a category by moving its last row out means deleting the <h3> — an empty table renders as a heading with nothing under it, and the category filter offers a value that matches nothing. Update the mapping's category/anchor to the new heading in the same edit, or the wiring suite's verbatim-category assertion fails.
section in the mapping index names the reference section that holds the row, and the analyzer's "See Reference Guide" link resolves through it. Sources with more than the two oss/plus sections must map all of them (sectionIdFor in migration-haproxy.js); a two-way === 'plus' ? … : 'mappings' ternary silently sends every ConfigMap, CRD and flag finding to #mappings.
The wiring suite asserts placement per page from the rendered rows, so this holds for every tool the branch ships. It reads the source side of each comparison to decide the surface — which is why a ConfigMap example must actually show kind: ConfigMap rather than an annotated Ingress.
Ordering and structure rules
- Annotation mapping rows within each category table are sorted alphabetically by the community annotation name (left column).
- Within a single row, when multiple annotations are listed on either side, they are in alphabetical order.
- "No direct equivalent" rows (NIC-only annotations) go at the end of their category table, after all community-to-NIC mappings.
- NIC-only annotations must not be bundled into community mapping rows. If an NIC annotation has no community equivalent it gets its own "No direct equivalent" row — never grouped into a row that maps community annotations.
- Both sides of every comparison are complete manifests —
apiVersion, kind, and the metadata the values hang off, on the target side as much as the source side. A bare annotations: or data: block cannot be pasted or diffed against a real cluster, and a fragment facing a full manifest turns the panel's diff into a shape mismatch. Conventions already on the page: Ingress → name: my-app; community ConfigMap → name: ingress-nginx-controller; NIC ConfigMap → name: nginx-config; no spec:, and no namespace. Leading document-level comments stay at indent 0 with the manifest below them; a comment-only block ("# No direct equivalent") declares nothing and is exempt. Each --- document is wrapped separately, and each needs its own name when they coexist (my-app-master / my-app-minion). The wiring suite asserts it per page — every comparison example is a complete manifest.
- Every NIC-side line carrying a translated value names its community source in a trailing comment, two spaces before the
#: client-max-body-size: "10m" # proxy-body-size. It goes after the value, or after the | on a block scalar. Several sources collapsing into one line join with + when they combine (# enable-cors + cors-*, # otlp-collector-host + otlp-collector-port) and / when they are alternatives (# log-format-escape-json / log-format-escape-none); add a parenthetical when the mapping is not obvious (# limit-rps (also covers limit-rpm)). A commented parent covers its children — errorPages: # custom-http-errors means the codes: beneath it stays bare, as do / under . : scaffolding a resource needs anyway (, , , ), required companions that translate nothing ( beside ), and NIC-only constructs on a "No direct equivalent" row — naming a source there would invent one. Where the value itself is the puzzle rather than its origin, the comment documents the value instead (). The wiring suite asserts the mechanical half — — because which lines are translated is a judgement no regex can make.
Accuracy: check all four, not just "does it exist?"
Every annotation, ConfigMap key, CRD field or feature documented here must exist in the version the tool's Version Reference banner names. Verify with mcp__github__get_file_contents against that tag — not main, and never from memory. Never document unreleased features.
Record the result. When you complete the four-point check for a mapping, add
verified: "<tag>" to it. The Version-reference banner makes one claim about all
57 mappings at once, so a version bump silently re-asserts every one of them;
test-analyzer.js prints how many carry a tag and how many are behind the
current pin, which turns an unbounded re-audit into a queue that shrinks. It
never fails on it — an empty queue is a goal, not a gate.
That rule catches fabrication. It does not catch staleness — a construct that does exist but is described with outdated semantics, wrong defaults or status codes, or an incomplete field set. Staleness is the more dangerous of the two because an existence check passes straight over it, and adversarial intuition about these constructs is wrong roughly half the time. So check all four against the tagged source in both repos:
- Exists — the annotation or field is in the pinned version.
- Semantics match — behaviour, status codes, defaults and value formats match the pinned source. Example: the community
auth-signin accepts a full URL, but NIC's externalAuth authSigninURI is a relative URI (CRD pattern ^/.*$), so the tool must strip the scheme and host rather than pass the URL through.
- Complete — no omitted fields or sub-options that exist in the pinned version and that a migrator would hit. Example: NIC's
accessControl Policy is allow xor deny — validation requires exactly one of allow/deny — so a source rule needing both becomes two Policies; collapsing it into one silently drops half the intent.
- NIC side checked both ways — NIC-side claims are neither overstated (e.g. "no HTTP fallback-service field" when VirtualServer/VirtualServerRoute upstreams have
backup/backupPort) nor understated, and any Plus-only NIC capability (e.g. least_time, ExternalName upstream services) is labelled as such.
Research sources
Prefer GitHub MCP tools over WebFetch.
- Community annotations:
kubernetes/ingress-nginx → docs/user-guide/nginx-configuration/annotations.md. The repository is archived and controller-v1.15.1 is its final release, so this is a fixed target, not a moving one.
- NIC annotations:
nginx/documentation → content/nic/configuration/ingress-resources/advanced-configuration-with-annotations.md
- NIC CRD source:
nginx/kubernetes-ingress → pkg/apis/configuration/v1/types.go
- Published NIC docs: https://docs.nginx.com/nginx-ingress-controller/ — the VirtualServer/VirtualServerRoute, Policy, TransportServer and GlobalConfiguration resource pages under
configuration/
- Migration guide: https://docs.nginx.com/nginx-ingress-controller/install/migrate-ingress-nginx