fix(group-tools,audit): read member counts from the collection that carries one - #387
Conversation
…arries one `GET /v1/computer-groups` answers description, id, name and smartGroup and nothing else — wire-checked against Jamf Pro 11.32. Two call sites read a `memberCount` it has never sent, and both fell through to a zero: - `groupMemberCount` type-asserted `memberCount`, then `members`, then returned 0. So every group reported 0 members, and `group-tools list`, `list --empty` and `analyze --unused` were all wrong. On a 37-group tenant where 6 groups hold members (max 45), `list` reported 0 non-empty. - `checkEmptySmartGroups` read the same absent field behind `if count, ok := g["memberCount"].(float64); ok && count == 0`. `ok` was always false, the counter never incremented and the check returned (nil, nil) on every instance — `pro audit` could not emit its "Empty smart groups" finding at all, including on a tenant with 29 empty smart groups. The counts come from the two v3 collections that carry them: membershipCount on /v3/computer-groups/smart-groups and count on /v3/computer-groups/static-groups. Both share /v1's id space, so two paginated sweeps index the whole instance — against one request per group for the smart-group-membership and Classic static-group alternatives. Both are plain Jamf Pro API paths and both are served on the platform gateway, so this works unchanged on a token or oauth2 profile and on a gateway profile. Platform /v2/groups carries the same counts but is gateway-only and would have taken these commands with it. A count that could not be read is now distinguishable from zero. `groupCountIndex.count` returns (count, known); a summary row carries "unknown" rather than 0; `--empty` and `--unused` select only groups proved empty and say on stderr how many they left out. `--unused` also stops naming groups that hold members: it lists removal candidates, and a group holding 45 computers is not one. The fixtures were the reason this shipped. `/v1/computer-groups` mocks hand-fed a `memberCount` the API never returns, so the broken behaviour was green. They now carry the real payload shape, plus regression tests: a count is never derived from a field on the listed group, a failed or countless sweep reads as unknown rather than empty, and the audit check fails loudly on an instance serving only the countless collection. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
5a5d417 to
448b021
Compare
|
Rebased onto main (now at 64671e9, after #389 and #388 merged) and force-pushed: 5a5d417 → 448b021. Conflict resolution — one hunk, Follow-up applied in the same pass, as planned: CHANGELOG entry added, covering the three script-visible changes: Checks: Live check on a second tenant (platform-mockingbird, gateway auth), independent of the numbers measured on platform-nmjspp: One correction to the acceptance criteria above:
🤖 Generated with Claude Code |
|
Adding the With #390's guard applied on top of this branch, against platform-mockingbird:
The 4-row difference is the clearest statement of what this PR does: four groups that actually hold members are no longer offered as removal candidates. On main every count read as 0, so For the avoidance of doubt about the earlier note: those 23 rows on main are not evidence of anything about this branch. #390 is independent of this PR (pre-existing crash, reproduces on main) and the two auto-merge — verified by cherry-pick, no conflict in 🤖 Generated with Claude Code |
Two bugs, one root cause
GET /v1/computer-groupsanswersdescription,id,nameandsmartGroupand nothing else — wire-checked against Jamf Pro 11.32.0. Two call sites read amemberCountit has never sent, and both turned the failed type assertion into a zero rather than into "unknown".1.
pro group-toolsreported every group as empty.groupMemberCountassertedmemberCount, thenmembers, thenreturn 0. Sogroup-tools list,list --emptyandanalyze --unusedwere all wrong. On a 37-group tenant where 6 groups hold members (max 45),listreported 0 non-empty andanalyze --unusednamed 30 of 31 groups as removal candidates.2.
pro audit's empty-smart-group check could never fire.checkEmptySmartGroupsread the same absent field behindif count, ok := g["memberCount"].(float64); ok && count == 0.okwas always false, so the counter never incremented and the check returned(nil, nil)on every instance — including one with 29 genuinely empty smart groups. The check is registered and looks healthy; it just cannot produce a finding.The two are the same missing field read two ways: one discarded the
okand lied, the other honoured it and went silent.The fix
Counts come from the two v3 collections that carry them —
membershipCounton/v3/computer-groups/smart-groupsandcounton/v3/computer-groups/static-groups. Both share/v1's id space, so two paginated sweeps index the whole instance.A count that could not be read is no longer reported as zero.
groupCountIndex.countreturns(count, known); a summary row carries"unknown"rather than0;--emptyand--unusedselect only groups proved empty and say on stderr how many they left out.analyze --unusedalso stops naming groups that hold members. It lists removal candidates, and a group holding 45 computers that no policy scopes is a question for an administrator, not a group to delete.Longand the flag help say so.Auth paths
Both v3 paths are plain Jamf Pro API paths and both are served on the platform gateway, so this works unchanged on a token or oauth2 profile and on a gateway profile. Platform
/v2/groupscarries the same counts but is gateway-only and would have taken these two commands with it.Cost: two extra paginated sweeps per
group-tools list/analyze --unused, fixed regardless of group count — against one request per group for thesmart-group-membershipand Classic static-group alternatives (~500 requests on a 500-group instance versus two).pro auditis unchanged: one sweep before, one after.Why this shipped green
The fixtures hand-fed a
memberCountthe real API never returns, so the broken behaviour passed. They now carry the real payload shape, plus regression tests: a count is never derived from a field on the listed group, a failed or countless sweep reads as unknown rather than empty, and the audit check now fails loudly on an instance serving only the countless collection (the old code answered(nil, nil)).Verification
make build,go test ./...,make lint(0 issues) all pass.Live, on a gateway profile and on an oauth2 profile against the same instance:
The static-group
countfield is wire-confirmed, not spec-inferred: loading 12 computers into an empty static group made it report 12, matchingpro groups list, and removing them returned it to 0.Notes for review
--page-sizework in fix!: --all pages at the endpoint's maximum, not a hard-coded 100 #389 (issue Computer-Inventory--alldoesn't respect--page-size#385), incheckEmptySmartGroups: that branch changes the/v1/computer-groupsfetch line's page size, this branch deletes the line. Resolution is take-this-side. Trial-merged both together: build green, full suite green, lint clean, all behaviours confirmed live on the combined binary.PageSizeFromPathalready onmain, rebasing this branch resolves the conflict above and changesgroupCountIndex.sweep's literal100in the same pass — one resolution, no dangling follow-up. The other order leaves that literal onmainuntil someone remembers it. fix(blueprints): render a YAML export as the document JSON renders #388 is independent of both.groupCountIndex.sweeppasses a literal100; it should becomePageSizeFromPathonce that constant exists. Left as100here so the branch compiles standalone againstmain. That path has no declared page-size maximum, so the constant resolves to 2000 there — safe, but do not raise it past that.CHANGELOG.mdentry, to avoid churn against the same in-flight branch. This does warrant one before merge:memberCountcan now be the string"unknown",--unusedreturns fewer rows, andpro auditgains a finding that never appeared.group-tools members <name>still returns bare{"id": ...}rows. Names would cost one/v4/computers-inventory/{id}per member, or an RSQL filter whose URL length is unbounded in member count. Separate change.🤖 Generated with Claude Code