# Migration Tool

> 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.

- Skill: `nginx/migration-tool` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nginx/migration-tool`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nginx/migration-tool/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: nginx (https://skillmd.com/u/nginx)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nginx/migration-tool

---


# 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.

```bash
grep -n '<section id='  ingress-nginx-migration.html   # 12 top-level sections
grep -n '<h3 id='       ingress-nginx-migration.html   # 49 category headings
```

`.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:

```bash
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`:

```js
{ 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:

1. the mapping in `assets/js/migration-ingress-nginx.js`
2. its generator, if it declares `templateFn`
3. the reference-table row in the HTML
4. the example YAML in that row's expanded panel — **generated output is the
   truth, the example follows it**
5. `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.warn`s 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:

1. **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.
2. **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 `number:`/`size:` under `buffers:`. **Three kinds of line stay bare**: scaffolding a resource needs anyway (`service`, `port`, `host`, `pass`), required companions that translate nothing (`real-ip-recursive` beside `set-real-ip-from`), 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 (`# on | off | merge`). The wiring suite asserts the mechanical half — `mapping comments agree across a row's approach tabs` — because which lines are translated is a judgement no regex can make.
- **Collapsed cells stay terse.** The always-visible cells (both columns of a `tr.expandable`) show only badges + `<code>` + a short blurb of at most ~6 words — `No direct equivalent`, `Not applicable`, `No direct equivalent (use <code>basicAuth</code>)`. Never put a full explanatory sentence, caveat or workaround in a collapsed cell. Any such explanation belongs in the expanded panel (`tr.example-row`) as an `info-box` banner: `info-box warning` for hard "no equivalent / no replacement" cases, with a bold lead-in like `<strong>No direct equivalent:</strong>`, and `info-box note` for softer guidance. A `warning` added alongside an existing `note` precedes it.

## 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:

1. **Exists** — the annotation or field is in the pinned version.
2. **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.
3. **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.
4. **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

