feat(compat): version fallback for older Jamf Pro tenants + spec version embedding#223
Conversation
…ding Implements issue #215 in two parts. ## Part 1 — Per-endpoint version fallback Generated GET/DELETE commands now retry against lower API versions on 404. The `versionFallback` tracker (emitted into registry.go) tries the primary path first; on 404 it walks FallbackPaths in descending version order, warns to stderr on the first successful retry, and reuses the resolved path for all subsequent paginated calls without re-probing. Generator changes: - `Operation.FallbackPaths []string` populated by `deduplicateVersionedOps` for GET/DELETE ops where multiple versions coexist in the same spec file (e.g. /v3/foo and /v2/foo → winner carries [/v2/foo] as fallback). - `resourceTemplate` emits `vft := newVersionFallback(...)` and `vft.do(...)` in place of `ctx.Client.Do(...)` when FallbackPaths is non-empty. - `registryTemplate` gains `versionFallback`, `versionFallbackApply`, `versionFallbackIs404`, `versionFallbackDo` helpers. Live verified on 11.27.1 tenant (CLI built against 11.28.0): - `mobile-device-groups-smart-groups`: fell back v2→v1, returned data ✓ - `computer-groups-smart-groups`: fell back v3→v2, returned data ✓ Known limitation: `computer-groups-static-groups` (v3) has no fallback to `static-computer-groups` (v2) because they live in different spec files and `deduplicateVersionedOps` only pairs versions within a single file. `static-computer-groups` still serves v2 on older tenants. A cross-spec post-merge fallback pass is tracked in issue #215. ## Part 2 — Spec version embedding and tenant version check `make sync-spec` now requires `JAMF_PRO_VERSION=<version>` (mandatory), writes it to `specs/.spec-version` (git-tracked), and `make build` embeds it via `-X main.specProVersion`. On each authenticated Pro command invocation, a non-fatal GET to `/v1/jamf-pro-version` compares the live tenant version to the embedded spec version and warns once if the tenant is older: warning: tenant is on Jamf Pro 11.27.1; this CLI was built against 11.28.0 — some commands may not be available Suppressed by `--no-version-check`, `JAMF_NO_VERSION_CHECK` env, or `--quiet`. Gateway mode verified: probe rewrites correctly to `/api/pro/v1/tenant/{id}/jamf-pro-version`. `jamf-cli version` and `version -o json` now include `specProVersion`. ## Spec sync Regenerated against nmartin.jamfcloud.com (Jamf Pro 11.28.0): - New v2/v3 endpoints for mobile-device-groups smart/static subgroups - New v3 endpoints for computer-groups smart/static subgroups - New commands: computer-groups-smart-groups, computer-groups-static-groups, m-2-ms - smart-computer-groups removed (replaced by computer-groups-smart-groups) - Updated backup resource key: smart-computer-groups → computer-groups-smart-groups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The backup command uses raw HTTP calls (not the generated vft mechanism), so the spec version bump from v1→v2 and v2→v3 for mobile-device and computer smart-groups broke backup on older tenants (e.g. 11.27.1). Fix: add FallbackListPath/FallbackGetPath to BackupEndpoint (generator emits them from op.FallbackPaths), and update listResourceItemsAndMaps to retry on 404 against the fallback list path and update def.GetPath to match. Live verified: backup --resources smart-groups on 11.27.1 now falls back correctly and exports 5 objects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ktn-jamf
left a comment
There was a problem hiding this comment.
Warning
/vN path on 404) plus a startup tenant-version check, bundled with an 11.28.0 spec sync. The core fallback mechanism is well-built and well-tested; the gaps are around feature coverage, a default-on probe, and a breaking command rename.
Blocking: (1). Please also make a conscious call on (2) and (3).
The fallback engine itself is solid — pagination reuses the resolved version without re-probing, the warning fires once, fallbacks are ordered newest-first, and make verify-generated confirms the committed generated code matches the templates. The findings below are about the edges of the feature, not the core.
🟧 (1) (correctness/UX, c=75) — internal/commands/pro/generated/registry.go:597 (resolveNameToIDForApply): name-based operations silently bypass the fallback on older tenants.
The headline promise is "commands work on older Jamf Pro tenants." But fallback is only wired into the direct GET/DELETE-by-id paths and the list command. Every name-resolution path issues a raw client.Do against the primary /v3 path:
get --name,delete --name,patch --name,apply(upsert-by-name) →resolveNameToIDForApplylists/v3/...directly- bulk
delete --from-filewith names → same helper (computer_groups_smart_groups.go:441)
So on an older tenant a user gets an inconsistent surface: pro computer-groups-smart-groups list succeeds (falls back to v2), but pro computer-groups-smart-groups get --name "Foo" fails with a confusing looking up "Foo": resource not found (HTTP 404). The PR's "Known limitation" section documents the cross-spec static-groups case but not this one, which is broader (it affects every versioned resource that has a fallback).
Suggested fix — pick one:
- Document it: add this to the Known-limitations section so the inconsistency is intentional and discoverable, or
- Thread it through: pass the resource's
FallbackPathsintoresolveNameToIDForApply(and the bulk path) and route its list call through aversionFallback, mirroring the list command.
Fixed when: either the limitation is documented in the PR/README alongside the static-groups note, or get --name/apply/delete --name on a versioned resource fall back to the lower version the same way list and get <id> do.
🟧 (2) (design/performance, c=75) — internal/commands/root.go:631: the tenant-version probe runs on every authenticated Pro command with no cross-invocation caching.
checkTenantVersion fires in PersistentPreRunE for every Pro command (release and local builds — specs/.spec-version is populated, so specProVersion != "unknown" is true locally too). It's a synchronous /v1/jamf-pro-version GET before the command's real work, which roughly doubles latency for fast single-shot commands (get, a one-page list), and a non---quiet shell loop pays one probe per invocation.
It's reasonably gated (--quiet, --no-version-check, JAMF_NO_VERSION_CHECK, 5s timeout, non-fatal), so this may be an acceptable trade — but it's a default-on network call on a very hot path, which is exactly the kind of precedent worth deciding deliberately rather than by default.
Suggested fix: cache the resolved tenant version per-profile with a short TTL (e.g. a line in the profile state / a ~/.config/jamf-cli cache file), so the probe runs at most once per TTL window instead of once per command. Alternatively, probe lazily only after a command actually returns a 404. If the per-command cost is intended, a one-line note in the PR confirming the team accepts it is enough.
Fixed when: the version check either no longer adds a round-trip to every invocation (cached/lazy), or the per-command probe is explicitly acknowledged as the intended behavior.
🟧 (3) (breaking change/consistency, c=75) — command rename smart-computer-groups → computer-groups-smart-groups with no alias, and an asymmetry with static groups.
smart-computer-groups was removed (file renamed to computer_groups_smart_groups.go); any script calling pro smart-computer-groups … breaks with no alias or deprecation path. Meanwhile static-computer-groups was kept and computer-groups-static-groups was added — so static groups now expose two overlapping commands while smart exposes one. On a new tenant a user sees both static-computer-groups and computer-groups-static-groups but only computer-groups-smart-groups, which is hard to reason about.
Suggested fix:
- Add an alias so
smart-computer-groupskeeps resolving (seeinternal/commands/aliases.go), or call out the rename in the PR as an intentional breaking change. - Add a short note (README/help or the PR body) explaining why static keeps both commands (the cross-spec fallback gap) while smart has one, so the asymmetry reads as deliberate.
Fixed when: the old smart-groups invocation still works (alias) or is documented as removed, and the static-groups duplication is explained.
Nice-to-have suggestions (4 items)
🟩 (4) (cleanup, c=100) — internal/commands/groups.go:177: stale proGroupMap entry. "smart-computer-groups": groupComputers references a command that no longer exists. TestApplyProGroups_AllCommandsGrouped only checks the forward direction (every command has a group), so the orphan passes CI but is dead config.
- "smart-computer-groups": groupComputers,
"static-computer-groups": groupComputers,
"computer-groups-smart-groups": groupComputers,
"computer-groups-static-groups": groupComputers,Fixed when: the orphaned entry is removed.
🟩 (5) (code-quality/UX, c=100) — the new m2m tag auto-pluralizes to the awkward command m-2-ms (pro m-2-ms get for a single /v1/m2m/tenant-id GET). Per CLAUDE.md, add a resourceNameOverrides entry in generator/parser/parser.go to map m2m to something readable (e.g. m2m or m2m-tenant-id), then make generate.
Fixed when: the command name is no longer m-2-ms.
🟩 (6) (test coverage, c=75) — checkTenantVersion has no direct test. compareProVersions is well covered, but the probe/parse/warn/suppress flow (including the specProVersion == "unknown" and --no-version-check gates) isn't exercised. A small test with a mock cliClient returning a version JSON, asserting the warning fires below / is silent at-or-above the spec version, would lock in the behavior.
Fixed when: a test covers the warn-vs-silent decision and at least one suppression gate.
🟩 (7) (correctness, c=75) — generator/main.go:474: backup fallback is one level deep. FallbackListPath/FallbackGetPath take only op.FallbackPaths[0], so backup/diff of a resource with a v3→v2→v1 chain only tries v2. The generated commands walk the full chain; backup doesn't. Fine for the current two-version resources, but worth a comment noting the single-level limitation so it's not mistaken for full parity.
Review coverage
- Design and architecture: reviewed the fallback engine (
versionFallbackin registry template), the parserdeduplicateVersionedOpschain-building + descending sort, and the backup-registry plumbing. Clean separation; flagged the name-resolution coverage gap (1) and the every-command probe precedent (2). - Correctness: traced
do/versionFallbackApply/compareProVersions. Path-prefix swap, pagination reuse, 404-vs-non-404 handling, and version parsing (incl. build-suffix strip) all check out. nil-resp-before-defer incheckTenantVersionis safe. - Security: new
/v1/jamf-pro-versionprobe uses existing auth,LimitReader(512), reads onlyversion. RSQL escaping inresolveNameToIDForApplyis pre-existing/unchanged. No new injection or secrets exposure. - Performance: substantiated the extra per-command round-trip (2). Pagination correctly avoids per-page re-probing.
- Test coverage:
TestVersionFallback_*(7 cases) andTestCompareProVersions(10 cases) are solid; ran both green.checkTenantVersionitself untested (6). - Reliability: version check is non-fatal on every error path (auth, timeout, parse, empty version); fallback returns the original 404 when exhausted. Good.
- Code quality: flagged stale group entry (4) and
m-2-msnaming (5). - [na] Simplification:
isBackup404/versionFallbackIs404duplication is across package boundaries (commandsvspro/generated) — unavoidable without a shared helper, not worth churning. - [na] Frontend concerns: no UI.
- Project rules compliance: respects the generated-code boundary (changes are in templates + parser;
make verify-generatedconfirms committed output matches). Credential policy untouched. No.claude/rules/dir exists.
Scope of review
- Diff: 50 files, +3584/−565. High-risk (touches generator templates, API version logic, build embedding).
- Read in full:
generator/parser/{generator,parser,types}.go,generator/main.go,internal/commands/{root,version}.go,cmd/jamf-cli/main.go,internal/commands/pro_backup.go,Makefile, the newversionFallbackruntime + tests, generatedcomputer_groups_smart_groups.go(delete/list paths),resolveNameToIDForApply. - Verified:
go testfor both new test files (green),make verify-generated(generated code up to date),go build ./internal/commands/... ./cmd/...(clean). - Treated as derivative (not line-by-line): the bulk of
internal/commands/pro/generated/*.goandspecs/*.yaml— verified for consistency viaverify-generatedrather than reading each. Spot-checked the MdmCommands spec change (metadata-only) and mobile-device-group fallback presence. - The PR bundles two features (fallback + version check) with a spec sync. Defensible (the sync provides the multi-version endpoints that demonstrate fallback), but it inflates the diff; a reviewer should lean on
verify-generatedfor the generated/spec portion.
What's done well
✅ Pagination correctly resolves the version once and reuses it (activePath), avoiding a re-probe per page — and there's an explicit test for it (TestVersionFallback_ReusesResolvedPath).
✅ The version check is defensively non-fatal across every failure mode and offers three independent suppression switches plus a dev-build guard (specProVersion != "unknown").
✅ make verify-generated passing means the generator changes and committed output are genuinely in sync — no hand-edited generated files.
This covers all findings — addressing the above gets this PR to merge-ready.
🤖 Reviewed with the Jamf Claude Code skill (pr-review v1.7.0) · model: claude-opus-4-8[1m]
- Cache tenant version check per profile (24h TTL) so fast commands don't pay a round-trip on every invocation; fall through to live probe on miss/stale or when using env-var auth - Change checkTenantVersion to accept registry.HTTPClient + io.Writer for testability; add 7 tests covering warn/silent/cache/stale paths - Add smart-computer-groups alias → computer-groups-smart-groups so existing scripts keep working after the 11.28.0 rename - Remove stale smart-computer-groups proGroupMap entry (alias doesn't affect cobra .Name(), so the key was dead config) - Override m-2-ms → m2m in resourceNameOverrides; regenerate (renames m_2_ms.go → m2m.go, updates backup/smoke/registry) - Add single-level fallback comments in generator/main.go backup paths - Document --name bypass and static-groups asymmetry in PR body Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use _, _ = fmt.Fprintf(w, ...) on both warning paths in checkTenantVersion to satisfy errcheck (generic io.Writer unlike os.Stderr is not excluded by golangci-lint defaults) - Add 'Products' to Quick Access pillar in catalog.js; the group was already in commands.json but unregistered after the preflight check landed via the main merge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
All findings addressed — here's the rundown: (1) --name fallback bypass — documented as Known limitation #2 in the PR body with a workaround. Threading it through (2) Per-command version probe — (3) Breaking rename + static asymmetry — Added (4) Stale proGroupMap entry — Removed. (5) m-2-ms naming — Added (6) checkTenantVersion tests — 7 cases added: warn below spec, silent at/above spec, silent on unknown spec (no HTTP call made), silent on HTTP error, cache hit (no HTTP call), stale cache (triggers live probe). (7) Single-level backup fallback — Added comments at both CI was also failing post-push on two unrelated things that came in via the main merge: |
ktn-jamf
left a comment
There was a problem hiding this comment.
Tip
✅ Merge-ready (re-review of 675ac91..eba9cd0) — All 7 findings from the prior review are addressed: 6 fully, and the per-command probe (2) is substantially improved with a 24h per-profile cache. One non-blocking follow-up remains — see (8). No new bugs, security issues, or regressions; verify-generated, the new tests, and go build all pass.
Verified the fix commit (10aed4a) and the CI follow-up (eba9cd0). Generated code is consistent after the m2m rename, the cache tests are hermetic (t.TempDir() + XDG_CONFIG_HOME), and the errcheck/catalog.js CI fixes are benign.
Prior findings status (7)
| # | Finding (original c) | Status | Notes |
|---|---|---|---|
| (1) | --name/apply don't fall back on older tenants (c=75) |
✅ Fixed | Documented as Known Limitation #2 in the PR body with a list→get <id> workaround; threading through resolveNameToIDForApply deferred to a follow-up. This was one of the two accepted options. |
| (2) | Version probe on every command, uncached (c=75) | 🟡 Partially fixed | 24h per-profile cache added (.version-cache.json, atomic write, fresh tests). Works for explicit -p, but the default-profile / JAMF_PROFILE path still probes every command — see new finding (8). |
| (3) | Breaking rename + static-groups asymmetry (c=75) | ✅ Fixed | smart-computer-groups alias added in aliases.go; asymmetry explained in the PR body. |
| (4) | Stale proGroupMap entry (c=100) |
✅ Fixed | Removed (groups.go). |
| (5) | Awkward m-2-ms command name (c=100) |
✅ Fixed | resourceNameOverrides maps m-2-ms→m2m; regenerated to m2m.go. verify-generated clean. |
| (6) | checkTenantVersion untested (c=75) |
✅ Fixed | 7 hermetic tests: warn/silent/unknown/HTTP-error/cache-hit/stale-cache. |
| (7) | Backup fallback one level deep (c=75) | ✅ Fixed | Documented with comments at both FallbackPaths[0] sites in generator/main.go. |
Nice-to-have suggestions (1 item)
🟩 (8) (design/performance, c=100) — internal/commands/root.go:694: the version cache keys on the raw profile flag, so the most common interactive path bypasses it.
The call is checkTenantVersion(proClient, specProVersion, profile, os.Stderr), but profile is the unresolved -p flag. The effective profile (resolvedProfile, already computed at line 664 and stored in cliCtx.ProfileName) falls back to JAMF_PROFILE and then cfg.DefaultProfile. After pro setup, a user typically runs commands without -p, so profile == "" → the cache is neither read nor written → they pay a live /v1/jamf-pro-version round-trip on every command. The cache only engages when -p <name> is passed explicitly, which is the minority case — so the optimization that finding (2) prompted doesn't reach the users it was meant to help.
Suggested fix: key the cache on cliCtx.ProfileName (the resolved profile) instead of the raw flag:
- checkTenantVersion(proClient, specProVersion, profile, os.Stderr)
+ checkTenantVersion(proClient, specProVersion, cliCtx.ProfileName, os.Stderr)One nuance to decide consciously: if a user supplies env-token auth (JAMF_TOKEN) and has an unrelated DefaultProfile configured, resolvedProfile would key the env tenant's version under that profile name. If that edge matters, key on the resolved instance URL instead — it identifies the tenant regardless of auth source and covers every case. Either is strictly broader than the current -p-only behavior.
Fixed when: running a command with a configured default profile (no -p) reads/writes the cache and skips the probe within the TTL window — verifiable by asserting only one HTTP call across two invocations, or by inspecting .version-cache.json after a default-profile command.
Scope of re-review
- Baseline: my prior review at
675ac91(CHANGES_REQUESTED). Diffed675ac91..eba9cd0. - New commits reviewed:
10aed4a(fix: address findings) andeba9cd0(CI: errcheck + catalog.js). The interveningmainmerge (7a5b516) brought in an unrelated jamfprotect-go-sdk 0.5.0 bump — not part of this feature, not re-reviewed. - Focused on the new surface: the per-profile version cache in
root.go(+88), the 7 new tests, thealiases.goalias, and them2mregeneration. Confirmed no new findings beyond (8). - Verified:
make verify-generated(up to date),go test ./internal/commands -run 'TestCheckTenantVersion|TestCompareProVersions'(green),go build ./...(clean),m_2_ms.gofully removed with no stale references. - New comments since baseline: one from the author (@neilmartin83) summarizing the fixes — no open questions for the reviewer. No other reviewers commented.
Review coverage
- Design and architecture: cache is a self-contained, non-fatal layer with atomic writes; the one gap is the cache key (8).
- Correctness: cache read/write gating (
profileName != ""), TTL freshness check, and re-comparison againstspecVersionon cache hit all verified. Theunknownguard moved cleanly inside the function. - Security: cache file written
0o600in the config dir; stores only a version string + timestamp. No secrets, no injection surface. A corrupt cache self-heals (unmarshal error → live probe). - Performance: the cache's reach is the substance of (8); pagination/fallback hot paths unchanged from the approved baseline.
- Test coverage: 7 new tests are hermetic and cover the warn/silent/cache/stale matrix.
""-profile tests never touch the filesystem. - Reliability: every error path in
checkTenantVersionremains silent/non-fatal; the io.Writer refactor preserves behavior. - Code quality: alias comment is clear;
m2moverride regenerated consistently. - [na] Simplification: no new redundancy introduced.
- [na] Frontend concerns: the
catalog.jschange is a one-line pillar registration, not UI logic. - Project rules compliance: generated-code boundary respected (
verify-generatedclean); credential policy untouched. No.claude/rules/dir.
🤖 Reviewed with the Jamf Claude Code skill (pr-review v1.7.0) · model: claude-opus-4-8[1m] · re-review
Resolve generated file conflicts by running make generate against the auto-merged spec sources, combining fallback path fields from main (PR #223) with the new classic-computer-groups and classic-mobile-device-groups entries from this branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Implements issue #215 in two parts.
Part 1 — Per-endpoint version fallback
Generated GET/DELETE commands retry against lower API versions on 404. A `versionFallback` tracker (emitted into `registry.go`) tries the primary path first; on 404 it walks `FallbackPaths` in descending version order, warns once to stderr on the first successful retry, and reuses the resolved path for all subsequent paginated calls (no re-probing per page).
Generator changes:
Live verified on 11.27.1 tenant (CLI built against 11.28.0):
Known limitations:
`computer-groups-static-groups` (v3) does not fall back to `static-computer-groups` (v2) because they live in different spec files and `deduplicateVersionedOps` only pairs versions within a single file. `static-computer-groups` continues to serve v2 on older tenants. A cross-spec post-merge fallback pass is out of scope for this PR.
`--name`, `apply`, `delete --name`, and `delete --from-file` (with names) resolve names by listing the primary API path only. They do not walk `FallbackPaths`. On a tenant older than the primary version, `list` and `get ` fall back correctly but `get --name` returns resource not found. Workaround: use `list` to find the numeric ID, then `get `. Threading fallback through name-resolution is deferred to a follow-up PR.
Part 2 — Spec version embedding and tenant version check
`make sync-spec` now requires `JAMF_PRO_VERSION=` (mandatory). It writes the version to `specs/.spec-version` (git-tracked) and `make build` embeds it via ldflag. On each authenticated Pro command, a non-fatal probe to `/v1/jamf-pro-version` compares the live tenant version against the embedded spec version and warns once if the tenant is older:
```
warning: tenant is on Jamf Pro 11.27.1; this CLI was built against 11.28.0 — some commands may not be available
```
The result is cached per named profile for 24 hours (`~/.config/jamf-cli/.version-cache.json`) so fast single-shot commands don't pay a round-trip on every invocation. Env-var-based auth always probes live (no profile key to cache under).
Suppressed by `--no-version-check`, `JAMF_NO_VERSION_CHECK` env, or `--quiet`. Gateway mode verified: probe rewrites correctly through the platform gateway. `jamf-cli version` / `-o json` now surfaces `specProVersion`.
Spec sync (nmartin.jamfcloud.com, Jamf Pro 11.28.0)
Test plan
🤖 Generated with Claude Code