DEV Community

René
René

Posted on

Your CI stack ships four YAML parsers and half of them are still on the 2009 spec

Everyone knows the Norway problem. country: NO becomes false, ha ha, quote your strings.

What gets skipped is the part that actually costs you an afternoon: YAML 1.2 fixed this in 2009, and a large share of the parsers in your pipeline never moved. So the interesting failure is not one parser being wrong. It is two parsers in the same pipeline being right in different ways, on the same file.

Take an inventory of your parsers

Most teams have never counted. A fairly ordinary Python-and-Kubernetes shop is running at least four:

Where Library Spec it resolves against
Ansible playbooks and vars PyYAML 1.1
kubectl apply sigs.k8s.io/yaml over go-yaml v2 1.1
A Go controller or operator you wrote recently go-yaml v3 1.2 core
Anything Node touches (config loaders, lint plugins, codegen) js-yaml v4 or yaml 1.2 core
A Java service reading config SnakeYAML 1.x 1.1
Ruby tooling Psych 1.1

Note js-yaml: v3 was 1.1 and v4 is 1.2. If you have a repo old enough to still be on v3 alongside a newer package on v4, you have the split inside a single node_modules.

The values they disagree about

This is the whole list that matters in practice. Everything else is trivia.

country:   NO          # 1.1 -> false        | 1.2 -> "NO"
enabled:   off         # 1.1 -> false        | 1.2 -> "off"
mode:      0644        # 1.1 -> 420 (octal)  | 1.2 -> 644 (decimal)
timeout:   12:30       # 1.1 -> 750 (base60) | 1.2 -> "12:30"
scale:     1e5         # 1.1 -> "1e5" (str)  | 1.2 -> 100000
version:   1.10        # both -> 1.1 (float)
Enter fullscreen mode Exit fullscreen mode

Three of those are silent data corruption with no error anywhere.

mode: 0644 is my favourite because it looks so safe. A 1.1 parser reads it as octal and hands you the integer 420, which is the file mode you meant. A 1.2 parser reads it as the decimal number 644, which is mode 1204 in octal, which is nothing you wanted. Same six characters, same file, and which one is correct depends entirely on which binary opened it.

12:30 as 750 is the sexagesimal type, a YAML 1.1 feature that existed so you could write durations as 1:30:00. It was removed in 1.2 for the obvious reason. Any time value, any port pair, any HH:MM in a config is exposed to it.

Two more that are not version differences but bite just as hard:

  • 1.10 is a float in every parser, and floats do not have trailing zeros, so your version pin silently becomes 1.1. There is no spec version where this behaves.
  • Duplicate keys are an error under 1.2 and a silent last-one-wins under PyYAML. A 400-line values file with the same key at line 40 and line 380 is a normal Tuesday, and Python will not tell you.

Where the two parsers meet

The single-parser case is survivable, because at least the file is consistently wrong. The one that eats a day looks like this.

Ansible templates a Kubernetes ConfigMap. Someone writes:

# group_vars/prod.yml
feature_legacy_import: no
Enter fullscreen mode Exit fullscreen mode

PyYAML resolves that to Python False. Jinja renders False into the manifest. The manifest now says:

data:
  feature_legacy_import: "False"
Enter fullscreen mode Exit fullscreen mode

And your Go service reads the ConfigMap, gets the string "False", and does strconv.ParseBool on it, which succeeds, so nothing errors. Except upstream someone "fixed" the vars file by quoting it to "no", and now the same code path receives "no", and ParseBool("no") returns an error, which your code treats as false with a logged warning nobody reads.

The flag is off in both cases. It is off for two entirely different reasons and only one of them is the one you configured.

yamllint will not save you here

Worth being precise, because people assume this is covered. yamllint has a truthy rule and it does flag bare yes/no/on/off. Turn it on, it is free.

It does not check octal ambiguity, sexagesimals, exponent notation, version-number truncation, or the case you actually care about, which is what two specific parsers each make of this file. It is a style linter. It reads your YAML as text, not as values.

There is also a per-implementation layer under the spec version. The YAML 1.1 type repository lists bare y and n as booleans, but PyYAML's resolver deliberately leaves them alone. So "it works in my parser" is not even a claim about YAML 1.1, it is a claim about one library's resolver. That is the level at which this stuff has to be checked.

What actually holds

In rough order of value:

  1. Quote every string that could be read as something else. Not every string, that is unreadable. The ambiguous ones: anything matching y|n|yes|no|on|off|true|false in any casing, anything with a leading zero, anything with a colon, anything shaped like a number that is not a number. Single quotes, since they pin it as a string under both specs.
  2. Schema-validate the parsed result, not the file. A JSON Schema on the loaded object catches 420 where you expected "0644". Linting the text cannot, because the text is fine.
  3. Use yaml.safe_load plus an explicit type coercion at the boundary. If a value must be a string, cast it there and stop trusting the resolver.
  4. Pin the parser version in your lockfile and know which spec it is. The js-yaml v3 to v4 upgrade is a behaviour change in your config semantics. It is not a patch bump.
  5. Prefer JSON for machine-generated config. No resolver, no types to guess, no Norway.

Checking a file you did not write

The gap in the tooling is that YAML validators tell you a file parses, which is never the question. The question is what it parses into, and whether two parsers agree.

I ended up building a linter for exactly that. Paste a workflow, a playbook or a manifest and every plain scalar that resolves differently under 1.1 and 1.2 comes back with both readings next to each other, plus duplicate keys, tabs and non-breaking spaces. There is a button that emits the file with every ambiguous value quoted. It runs client side, so you can paste a production values file into it without thinking about it.

The longer background on why the fix landed in 2009 and still has not reached your dependency tree is here.

Run it over your group_vars directory once. My honest expectation is that you find between two and five, and that one of them is a file mode.

Top comments (0)