Documentation
¶
Overview ¶
Package progroups holds the machinery the four Jamf Pro device-group constructs share: jamfplatform_pro_smart_computer_group, jamfplatform_pro_static_computer_group, jamfplatform_pro_smart_mobile_device_group and jamfplatform_pro_static_mobile_device_group, each with a singular data source, a plural data source and a list resource.
Why these exist at all ¶
jamfplatform_device_group already manages computer and mobile-device groups, smart and static, through Platform Services. It is the one an operator should reach for. What it cannot do is put a group in a Jamf Pro site: the /device-groups/v1 surface has no site field and never sends one. These constructs exist for that single gap, which is why every schema description says so and points back to jamfplatform_device_group — see DeviceGroupAlternative.
The wire laws behind the shared helpers ¶
All wire facts below were probed on 2026-09-17 against Jamf Pro 11.32.0 on the EU gateway under an environment-scoped integration, except where marked.
Every write is a full replace of the object's scalars. A body that omits `description`/`groupDescription` clears it to the empty string, and one that omits `siteId` clears the site to the "-1" sentinel. So every write emits every scalar; nothing relies on omission to retain.
`siteId` is mandatory on both mobile endpoints and optional on both computer endpoints. The mobile refusal is 403 with code INVALID_PRIVILEGE naming `field: siteId`, which reads as a permission problem and is not one — a site id that does not exist draws the same code with a null field. Because the always-emit rule above already sends `siteId` on every write, the asymmetry costs callers nothing; SiteRefusal translates the code when it does surface.
`assignments` is mandatory on every static write — create and update, computer and mobile. Omitting it answers 500 with an empty `errors` array and applies nothing. The SDK records the create half of this under "server bugs worth reporting" in its docs/WIRE-FACTS.md; the update half and the mobile endpoint behave identically. Static membership therefore always emits the key, and "leave membership unmanaged" is synthesised provider-side rather than by omitting the field.
The two static endpoints then disagree about what a populated `assignments` means, which is the single most important difference in this family. The computer endpoint (PUT /pro/v3/computer-groups/static-groups/{id}, a []string of Jamf Pro computer ids) REPLACES the membership, and [] clears it. The mobile endpoint (PATCH /pro/v2/mobile-device-groups/static-groups/{id}, a []Assignment carrying `selected`) DELTA-MERGES: an entry with selected true adds, selected false removes, other members are untouched, and [] is a no-op that leaves membership exactly as it was. Same field name, same concept, opposite semantics, one API generation apart.
A static group that has a site accepts only devices assigned to that same site. Anything else is 400 INVALID_DEVICE on `field: assignments` with nothing applied — probed both ways round, including moving a computer into the site and watching the identical request start succeeding.
That one code covers THREE different causes, which is why the diagnostic lists all three rather than guessing: a device id that does not exist, a device outside the group's site, and — computer groups only — a computer that is not managed. The third was found by minting a computer record, which Jamf Pro creates unmanaged: the static computer group refused it, and the identical request succeeded once `remote_management.managed` was flipped true.
The managed requirement applies to BOTH device types, and unlike siteId it is not an asymmetry. A static group of either kind refuses an unmanaged device.
That took two goes to get right, and the way it misled is worth recording. A hand probe minted a mobile record, tried to set managed, got a 500, and then watched a static group accept the device anyway — from which the obvious conclusion was that mobile groups take unmanaged devices. They do not: the 500 had applied the change regardless, so the device was managed by the time the group saw it. A write that reports failure and succeeds anyway will invert a conclusion drawn from its status code, so read the state back instead.
Deleting a group that anything is scoped to is 422 HAS_DEPENDENCIES, and the description names the dependents. It is permanent rather than propagation-blocked, so it is translated and returned, never re-issued or polled — the opposite of device_group's delete. See Dependencies.
Every /pro/v2 and /pro/v3 group endpoint accepts a group's platform UUID in place of {id} and always answers with the Jamf Pro numeric id, so the two id spaces are interchangeable on input. These constructs key on the Jamf Pro id (matching every other pro/ construct) and surface the platform UUID as a Computed attribute through ResolvePlatformID.
Index ¶
- Constants
- func BothRefusedDiagnostics(label string, modernErr, classicErr error) diag.Diagnostics
- func DeleteDiagnostics(err error, label string) diag.Diagnostics
- func DeviceGroupAlternative(dt DeviceType, kind GroupKind) string
- func FallbackWarning(label string, modernErr error) diag.Diagnostic
- func FallbackWriteDiagnostics(label string, sitePath, membersPath path.Path, modernErr, classicErr error) diag.Diagnostics
- func IsDependencyConflict(err error) bool
- func IsDuplicateName(err error) bool
- func IsInvalidDevice(err error) bool
- func IsSiteRefused(err error) bool
- func JamfProIDForPlatformID(ctx context.Context, client *pro.Client, platformID string) (string, error)
- func Label(dt DeviceType, kind GroupKind) string
- func MemberNoun(dt DeviceType) string
- func ModernWriteRefused(err error) bool
- func OlderInterfaceRefusedCriteria(err error) bool
- func PlannedValueReflected(planned, read attr.Value) bool
- func PlatformIDValue(byJamfProID map[string]string, jamfProID string) types.String
- func PlatformIDsByJamfProID(ctx context.Context, client *pro.Client, pd *providerdata.Data, dt DeviceType, ...) (map[string]string, diag.Diagnostics)
- func ReadBackAfterWrite[T any](ctx context.Context, read func(context.Context) (T, error), ...) (T, error)
- func RefusalDiagnostics(version, resourceType string) diag.Diagnostics
- func RequireSupportedJamfProVersion(ctx context.Context, pd *providerdata.Data, resourceType string) diag.Diagnostics
- func SiteIDForState(wire *string) types.String
- func SiteIDForWrite(planned types.String) *string
- func WaitUntilGone(ctx context.Context, read func(context.Context) error)
- func WriteDiagnostics(err error, label string, sitePath, membersPath path.Path) diag.Diagnostics
- type DeviceType
- type GroupKind
Constants ¶
const NoSiteID = "-1"
NoSiteID is the sentinel Jamf Pro stores and returns for a group that belongs to no site. It is the schema default for site_id, so an operator who says nothing about sites gets the same group they would from jamfplatform_device_group.
const PatchReportingCriterionPrefix = "Patch Reporting: "
PatchReportingCriterionPrefix is how Jamf Pro names the per-title criterion it generates for each configured patch software title: "Patch Reporting: " followed by the title's own name, e.g. "Patch Reporting: 1Password".
It is a literal because there is nothing to take it from — the criterion vocabulary is data Jamf Pro derives from a tenant's own patch titles, so no specification enumerates it and the SDK generates no constant. Only the acceptance canary uses it, to build the criterion it expects to be refused; the fallback itself triggers on the refusal rather than on the name, so no write path depends on this spelling.
Variables ¶
This section is empty.
Functions ¶
func BothRefusedDiagnostics ¶
func BothRefusedDiagnostics(label string, modernErr, classicErr error) diag.Diagnostics
BothRefusedDiagnostics reports a write both surfaces refused.
The group endpoint's message leads, because it is the precise one: it names the offending criterion or operator, where the older surface answers a bare "Problem with criteria". A misspelled criterion name is the case that lands here, and the operator needs to be told which name is wrong rather than that something, somewhere, is.
func DeleteDiagnostics ¶
func DeleteDiagnostics(err error, label string) diag.Diagnostics
DeleteDiagnostics turns a failed group delete into diagnostics, naming the dependency case.
Jamf Pro answers 422 HAS_DEPENDENCIES while anything is still scoped to the group, and its description lists the dependents by name, so the detail carries the server's own list rather than paraphrasing it.
This refusal is PERMANENT. It is not the propagation-blocked shape jamfplatform_device_group re-issues its delete for: nothing is catching up in the background, and the object will refuse for as long as the reference exists. Polling it would only spend the timeout, so callers translate and return.
The Terraform consequence is worth stating to the operator, because it is an ordering trap rather than a provider fault: dropping a group from a policy's scope and destroying the group in one apply can let Terraform sequence the destroy first, since the configuration edge is already gone from the new graph.
func DeviceGroupAlternative ¶
func DeviceGroupAlternative(dt DeviceType, kind GroupKind) string
DeviceGroupAlternative returns the paragraph every construct in this family opens its description with: that jamfplatform_device_group is the one to reach for, and that Jamf Pro site support is the only reason to prefer this.
It lives here, rendered from one template, so the advice cannot drift between sixteen schema descriptions or contradict itself between a resource and its own data source. It is deliberately the same sentence across the family rather than sixteen paraphrases of it.
func FallbackWarning ¶
func FallbackWarning(label string, modernErr error) diag.Diagnostic
FallbackWarning is the notice a successful fallback write emits.
It is a warning rather than silence because one guarantee is genuinely weaker on this path: the older surface does not check that an operator applies to the criterion it is paired with, so a criterion that cannot work simply matches nothing instead of being reported. An operator whose group comes back empty needs to know that is where to look.
It is a warning rather than an error because the configuration is not wrong — the admin UI offers these combinations and Jamf Pro stores them. Refusing would mean the practitioner cannot manage a group Jamf Pro itself is happy to have.
func FallbackWriteDiagnostics ¶
func FallbackWriteDiagnostics(label string, sitePath, membersPath path.Path, modernErr, classicErr error) diag.Diagnostics
FallbackWriteDiagnostics turns a failed fallback write into diagnostics, blaming the criteria only where the older interface refused them too.
Everything else goes through WriteDiagnostics, which names the duplicate name and the refused site Jamf Pro reports with a code. The criteria refusal that sent the write down this path travels with it as a warning: the request that failed is one nothing in the configuration asked for, so the error beside it would otherwise arrive with no account of why it was made.
Saying "your criteria are wrong" for a duplicate name sends the operator to edit a criterion that was never the problem, which is the whole reason this classifies rather than reporting one message for every failure.
func IsDependencyConflict ¶
IsDependencyConflict reports whether err is Jamf Pro refusing to delete a group because something is still scoped to it.
func IsDuplicateName ¶
IsDuplicateName reports whether err is Jamf Pro rejecting a group name that is already in use.
func IsInvalidDevice ¶
IsInvalidDevice reports whether err is Jamf Pro rejecting a static group's membership list.
func IsSiteRefused ¶
IsSiteRefused reports whether err is Jamf Pro refusing the site on a write.
func JamfProIDForPlatformID ¶
func JamfProIDForPlatformID(ctx context.Context, client *pro.Client, platformID string) (string, error)
JamfProIDForPlatformID reads a group's Jamf Pro id given its platform UUID.
Only the smart computer group needs this: its own GET carries no identifier, so a create that asked for the UUID has no other way to learn the numeric id it must store as the resource's id. The other three read it out of the read-after-create they already perform.
This one is NOT best-effort. The value becomes the resource id, so failing here means Create cannot record what it made, and returning a null id would orphan the group. The caller surfaces the error.
func Label ¶
func Label(dt DeviceType, kind GroupKind) string
Label names one of these groups the way the Jamf Pro admin UI does — "smart computer group", "static mobile device group" — so a diagnostic or an impact alert reads the way Jamf Pro's own does.
func MemberNoun ¶
func MemberNoun(dt DeviceType) string
MemberNoun returns what one of these groups contains, plural, in the admin UI's terms. Impact alerts count in these words.
func ModernWriteRefused ¶
ModernWriteRefused reports whether err is the group endpoints' refusal of a criterion or operator combination that the older group surface accepts.
It is deliberately narrow. An INVALID_FIELD that is not about a criterion or an operator — a malformed andOr value, for instance, which both surfaces reject — returns false, because retrying it would spend a second request to arrive at the same answer with a worse message.
func OlderInterfaceRefusedCriteria ¶
OlderInterfaceRefusedCriteria reports whether a fallback write failed because the older group interface refused the criteria as well.
That surface still validates criterion NAMES, so a misspelling is refused on both paths, and that is the one failure whose diagnosis really is the criteria. Every other way the second write can fail — a name already in use, a refused site, a gateway timeout — has nothing to do with them.
func PlannedValueReflected ¶ added in v1.6.0
PlannedValueReflected reports whether a value read back from Jamf Pro matches the planned one, treating an unknown planned value as matching, since Terraform accepts whatever the provider returns for it. It is the per-attribute test a settled predicate for ReadBackAfterWrite is built from.
func PlatformIDValue ¶
PlatformIDValue renders a looked-up platform id for state, nulling a miss.
A group absent from the bridge's map is reported as null rather than as an error: platform_id is only needed to reference the group from a Jamf Platform construct, so a gap costs nothing else and resolves on a later refresh.
func PlatformIDsByJamfProID ¶
func PlatformIDsByJamfProID(ctx context.Context, client *pro.Client, pd *providerdata.Data, dt DeviceType, kind GroupKind) (map[string]string, diag.Diagnostics)
PlatformIDsByJamfProID returns platform UUIDs keyed by Jamf Pro id for every group of one type and smartness, in a single request.
It serves the paths that hold a Jamf Pro id and no UUID: import, the plural data sources and the list resources. One request covers a whole page rather than one per group, because the filter narrows to the construct's own kind and the caller then looks each id up in the returned map.
Best-effort by design, and the caller must tolerate a missing key. Every failure yields (partial-or-empty map, warning) rather than an error, so a Read that has already fetched the group is never discarded over an attribute that exists only to reference the group from a Jamf Platform construct — the same contract device_group.resolveJamfProID keeps bridging these two id spaces in the other direction.
func ReadBackAfterWrite ¶ added in v1.6.0
func ReadBackAfterWrite[T any](ctx context.Context, read func(context.Context) (T, error), settled func(T) bool) (T, error)
ReadBackAfterWrite reads a group back after a write until settled reports that the read reflects that write, and returns the last read either way.
Jamf Pro 11.32.0 applies a PUT to /pro/v3/computer-groups/{smart,static}-groups after answering 202: a GET issued straight after the write returns the group as it was before it, and the next GET, about 150–300 ms later, returns the write (wire-verified 2026-10-20, eight consecutive writes, every first read one write behind). 11.33.0 answers the same 202 and reads fresh at once. A state builder fed the stale read hands Terraform the previous value and fails the apply with "Provider produced inconsistent result after apply".
The loop is bounded by readBackBound and by ctx, and a read that never settles is returned rather than refused: a server that genuinely stores a different value must still reach Terraform's own consistency check, which names the attribute, instead of a timeout that names nothing. On a tenant that reads fresh the first read settles and no extra request is made. A read error ends the loop at once and is returned to the caller.
func RefusalDiagnostics ¶
func RefusalDiagnostics(version, resourceType string) diag.Diagnostics
RefusalDiagnostics is the decision RequireSupportedJamfProVersion makes, split out from the version fetch so the table it implements can be tested without a client: refuse when version parses and lands inside the PI-1394 window, and say nothing otherwise — including when it will not parse.
func RequireSupportedJamfProVersion ¶
func RequireSupportedJamfProVersion(ctx context.Context, pd *providerdata.Data, resourceType string) diag.Diagnostics
RequireSupportedJamfProVersion refuses to configure a Jamf Pro device-group construct on a tenant whose version falls inside the window in which a Jamf-group "member of" criterion value comes back as a numeric group id instead of the authored group name — PI-1394, the 11.29 nested-smart-group rework, fixed in 11.30.1.
The window bounds are criteria.GroupRefRegressionVersion and criteria.GroupRefFixedVersion, so this gate and the workaround that the other criteria-bearing constructs carry can never disagree about where the window is.
This family refuses rather than working around. criteria.GroupResolver exists and would map the value back, but whether these particular endpoints (/pro/v3/computer-groups, /pro/v2/mobile-device-groups) regressed inside the window is unprobeable: the regression was characterised on classic /usergroups and Platform /device-groups, and a tenant inside the window was not available. Shipping an unverified value mapping against an endpoint that may never have needed it risks rewriting a value the server returned correctly; refusing tells the operator plainly to upgrade to 11.30.1, which is a supported release.
The refusal is deliberately CERTAIN-ONLY. It uses helpers.JamfProVersionInRangeStrict, not its fail-open sibling: a version string that will not parse must not block, because that would turn one unparseable response into an outage across every construct in the family. A tenant whose version cannot be read at all is likewise let through — the version fetch failing is ConfigurePro's business, and this gate adds nothing by duplicating its error.
Scope note: the regression reaches only a Jamf-group "member of" criterion value, so it cannot affect a static group, which has no criteria. The gate is applied across the whole family anyway, by maintainer decision, so the family has one answer about which tenants it supports. Narrowing it to the two smart constructs later is deleting the two static call sites.
func SiteIDForState ¶
SiteIDForState normalises a site id read back from Jamf Pro into the single user-facing sentinel.
The endpoints are not consistent about how they say "no site": the computer reads echo the string "-1", a PUT response echoes JSON null for the same group, and the classic read reports id -1 with name "NONE". Collapsing every one of them to NoSiteID keeps state stable across refreshes and keeps a group with no site from flipping between "-1" and null, which would surface as an ImportStateVerify difference or a post-apply inconsistency on the no-site path only — the shape STYLE_GUIDE §Server-derived computed fields warns reads as a flake.
func SiteIDForWrite ¶
SiteIDForWrite returns the site id to put on a write, always non-nil.
Every write in this family emits siteId unconditionally. Two independent reasons, and either alone would be enough: a computer write that omits it CLEARS the group's site, because the endpoints full-replace their scalars; and a mobile write that omits it is refused outright with 403 INVALID_PRIVILEGE. So there is no "leave the site alone" body to send, and the plan value — defaulted to NoSiteID — is the whole truth about the group's site.
An Unknown or Null plan value collapses to NoSiteID rather than to the empty string, which Jamf Pro does not accept as a site reference.
func WaitUntilGone ¶ added in v1.6.0
WaitUntilGone reads a group after a successful DELETE until the read reports not-found, for the same Jamf Pro 11.32.0 lag ReadBackAfterWrite outlasts: the DELETE answers 204 and a GET straight after it still returns the group, for about 150–250 ms (wire-verified 2026-10-20). Without it a destroy reports success while the group is still readable, so a create of the same name or a destroy check that follows at once sees it.
It never fails the delete. The DELETE itself succeeded, so a group that is still readable at the bound, or a read error other than not-found, is logged and the delete reported as done.
func WriteDiagnostics ¶
WriteDiagnostics turns a failed group create or update into diagnostics, naming the three cases Jamf Pro reports with a code but describes only generically.
membersPath anchors the membership case; pass a null path on a smart group, which has no membership attribute and cannot raise it.
Types ¶
type DeviceType ¶
type DeviceType int
DeviceType names which kind of device a construct groups, in the admin UI's terms, and selects the wording DeviceGroupAlternative renders.
const ( // DeviceTypeComputer covers the two computer-group constructs. DeviceTypeComputer DeviceType = iota // DeviceTypeMobile covers the two mobile-device-group constructs. DeviceTypeMobile )