Skip to content

fix(blueprints): render a YAML export as the document JSON renders - #388

Merged
neilmartin83 merged 2 commits into
mainfrom
fix/blueprint-export-yaml
Sep 18, 2026
Merged

neilmartin83 merged 2 commits into
mainfrom
fix/blueprint-export-yaml

Conversation

@neilmartin83

Copy link
Copy Markdown
Member

The bug

pro blueprints export -o yaml wrote each component's configuration as a sequence of the integer bytes of its own JSON text:

steps:
  - activationpredicate: null
    components:
      - configuration:
          - 123
          - 34
          - 67
          - 97

123 is {, 34 is ". -o json was correct all along and returned the real object.

Two causes, both yaml.v3: it has no equivalent of encoding/json's special case for json.RawMessage, so it renders the field as the []byte it actually is; and it does not read json tags, so it derives keys by lower-casing the Go field name — hence activationpredicate where JSON emits activationPredicate. apply --scaffold -o yaml took the same two through printScaffold, along with the --scaffold -o yaml of pro compliance-benchmarks apply and pro platform-device-groups create.

Why the read side had to move too

The round trip worked before this change, by accident. yaml.v3 can bind a sequence of integers back into a []byte, so the corrupt byte sequence was the only spelling of configuration its own reader accepted. Fixing the export alone trades a broken-looking document for one that no longer applies — verified in a throwaway probe: the old YAML applied fine, JSON-shaped YAML failed with parsing portable format: input is not valid JSON or YAML, and the lower-cased keys would have dropped activationPredicate silently regardless.

So this is one fix, not two:

  • jsonShaped rebuilds a value from its own JSON encoding. printScaffold renders that, and blueprintExport returns it from MarshalYAML.
  • The hook sits on the type, not in printExport, which is shared with 20+ Jamf Protect and Jamf School export sites. Their SDK input types carry no json tags at all (jamfprotect.AnalyticInput is untagged, so its JSON keys are already LongDescription), so normalising centrally would have rewritten their YAML into Go field names their own apply path does not read back.
  • unmarshalInput's YAML rung now normalises through JSON, so a document written either way binds — encoding/json matches keys case-insensitively, making this a strict superset — and a mapping reaches a json.RawMessage field. yaml.v3's own binding stays as the last rung for values encoding/json refuses and for input carrying no content.
  • refuseNonObjectComponentConfiguration refuses the one document that rung now binds and must not send: a YAML export from 1.31.0 or earlier, whose byte sequence is a valid JSON array and would otherwise travel to the gateway as the component's configuration.

Verification

make build, go test ./... (fully green), make lint (0 issues), make fmt clean.

Mutation-checked both halves: disabling MarshalYAML fails TestBlueprintExportYAMLCarriesTheSameDocumentAsJSON and TestBlueprintExportYAMLAppliesBack; disabling the normalised read rung fails TestBlueprintExportYAMLAppliesBack and TestUnmarshalInput_YAMLBindsAJSONTagKeyAndARawMessage. TestEveryScaffoldYAMLBindsBackThroughUnmarshalInput covers all three scaffold types through scaffold-YAML → unmarshalInput → JSON with no field dropped.

Live against a sandbox tenant:

$ jamf-cli pro blueprints export --name "JNUC Lab - Math Settings" -o yaml
steps:
  - components:
      - configuration:
          Calculator:
            BasicMode:
              AddSquareRoot: true
              Included: true
...

$ jamf-cli pro blueprints export --name "JNUC Lab - Math Settings" -o json | jq -c '.steps[0].components[0].configuration|keys'
["Calculator","SystemBehavior"]            # unchanged

$ jamf-cli pro blueprints export ... -o yaml > bp.yaml && jamf-cli pro blueprints apply --from-file bp.yaml -n --yes
  Resolved group "JNUC Lab - MacBook Fleet" → 88a72ad5-...
[dry-run] Would replace blueprint "JNUC Lab - Math Settings"      # exit 0

$ jamf-cli pro blueprints apply --from-file stale.yaml -n --yes   # a real 1.31.0 export, 359 byte-array lines
"message": "component com.jamf.ddm.math-settings: configuration is not an object
A YAML export written by jamf-cli 1.31.0 or earlier rendered it as a byte sequence.
Re-export the blueprint with this version and apply that file"

The stale file was produced by the actual 1.31.0 release binary, not hand-written. Export is read-only and every write above went through -n; the tenant still holds exactly its two pre-existing blueprints.

Notes for review

  • Merges clean against main and against the in-flight --page-size branch (Computer-Inventory --all doesn't respect --page-size #385), CHANGELOG.md included — trial-merged all three together with a green build, suite and lint.
  • Same defect class remains in the Jamf Protect and Jamf School export / --scaffold -o yaml output: their YAML keys are lower-cased Go field names rather than the JSON ones, so those documents are also not the same document as -o json. Self-consistent today, and after this change their read path binds either spelling. Making it uniform means giving those SDK-typed exports a canonical key spelling — a larger, separate change.

🤖 Generated with Claude Code

`pro blueprints export -o yaml` wrote each component's `configuration` as a
sequence of the integer bytes of its own JSON text and lower-cased every key
(`activationpredicate`), because yaml.v3 reads neither `json` tags nor
encoding/json's treatment of `json.RawMessage`. `-o json` was correct all
along. `apply --scaffold -o yaml` took the same two through printScaffold.

The rendering and the read side had to move together. The byte sequence was
the only spelling of `configuration` yaml.v3 could bind back, so fixing the
export alone would have traded a broken-looking document for one that no
longer applies — and the `json`-tag keys an export writes now bind to nothing
in yaml.v3's own struct binding, which matches the lower-cased Go field name.

- `jsonShaped` rebuilds a value from its own JSON encoding; printScaffold
  renders that, and `blueprintExport` returns it from `MarshalYAML`. The hook
  sits on the type rather than in printExport, which is shared with the Jamf
  Protect and Jamf School exports — their input types carry no `json` tags, so
  normalising there would rewrite their YAML into the Go field names their own
  apply path does not read back.
- `unmarshalInput`'s YAML rung is normalised through JSON, so a document
  written either way binds (encoding/json matches a key case-insensitively) and
  a mapping reaches a json.RawMessage field. yaml.v3's binding stays as the
  last rung for the values encoding/json refuses and for an input carrying no
  content.
- `refuseNonObjectComponentConfiguration` refuses the one document that rung
  now binds and must not send: a YAML export from v1.31.0 or earlier, whose
  byte sequence is a valid JSON array and would travel to the gateway as the
  component's configuration.

Verified on the sandbox tenant: the YAML export of `JNUC Lab - Math Settings`
carries `configuration` as a mapping with the `Calculator` and `SystemBehavior`
keys, the JSON export is byte-identical to before, and that YAML file applies
back (dry-run) with its scope resolved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@neilmartin83
neilmartin83 merged commit 64671e9 into main Sep 18, 2026
1 check passed
@neilmartin83
neilmartin83 deleted the fix/blueprint-export-yaml branch September 18, 2026 15:16
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.

2 participants