<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46656 `server/mdm/apple/vpp.do` retried transient Apple errors by **calling itself recursively**, with the rate-limit branch nesting `retry.Do` inside `retry.Do`. This change replaces the recursion with a single retry loop (respecting the prior 1 initial attempt + 3 retries), closes each response before retrying, honors Apple's `Retry-After` capped at 30s so that a multi-minute value can't block a synchronous request, and threads `context` through the VPP calls so the backoff is cancellable. The retry timings are otherwise unchanged from before. Following @sgress454 suggestion, I considered routing this through the shared `retry.Do` helper (a single attempt wrapped in `retry.Do` + an error filter) but figured out that: - retry.Do` owns its own wait schedule and its error filter returns an outcome enum rather than a duration, so it can't honor Apple's per-response `Retry-After` value. - also, I'd have to change the `retry` package to receive an extra `ctx` param so that the backoff is context-aware (which IMHO is more blast radius than this incident fix should carry). # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually **What was verified.** The new automated test cannot run against `main` (the fix changes the VPP function signatures and adds the retry knobs), so to confirm the actual failure mode I checked out `main` and ran a small repro that drives the VPP client against an Apple endpoint that always returns the rate-limit error. On `main`, the call **never returns** — `do()` recurses without bound — and the repro times out: ``` --- FAIL: TestReproUnboundedRecursionOnMain (10.00s) zz_repro_main_test.go:30: AssociateAssets did NOT return within 10s — unbounded retry recursion in do() on main FAIL FAIL github.com/fleetdm/fleet/v4/server/mdm/apple/vpp 10.642s ``` On this branch the same scenario returns a bounded error promptly. That behavior is covered by the new `TestDoRetryIsBoundedAndNonRecursive` (bounded rate-limit retries, `Retry-After` honored-but-capped, and context cancellation), and the full `server/mdm/apple/vpp` package passes. **I did not perform an end-to-end QA against a live Apple endpoint**. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed a server out-of-memory crash that occurred when Apple VPP API repeatedly returned transient errors during VPP operations, including app installs, user registration, and license seat releases. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
714 lines
24 KiB
Go
714 lines
24 KiB
Go
package vpp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
|
"github.com/fleetdm/fleet/v4/pkg/retry"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
)
|
|
|
|
// Asset is a product in the store.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/asset
|
|
type Asset struct {
|
|
// AdamID is the unique identifier for a product in the store.
|
|
AdamID string `json:"adamId"`
|
|
// PricingParam is the quality of a product in the store.
|
|
// Possible Values are `STDQ` and `PLUS`
|
|
PricingParam string `json:"pricingParam"`
|
|
// AvailableCount is the number of available licenses for this app in the organization unit
|
|
// specified by the VPP token.
|
|
AvailableCount uint `json:"availableCount"`
|
|
}
|
|
|
|
// ErrorResponse represents the response that contains the error that occurs.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/errorresponse
|
|
type ErrorResponse struct {
|
|
ErrorInfo ResponseErrorInfo `json:"errorInfo,omitempty"`
|
|
ErrorMessage string `json:"errorMessage"`
|
|
ErrorNumber int32 `json:"errorNumber"`
|
|
}
|
|
|
|
// Error implements the Erorrer interface
|
|
func (e *ErrorResponse) Error() string {
|
|
return fmt.Sprintf("Apple VPP endpoint returned error: %s (error number: %d)", e.ErrorMessage, e.ErrorNumber)
|
|
}
|
|
|
|
// IsMaxDevicesPerUserError reports whether err is an Apple VPP error indicating
|
|
// that a Managed Apple ID has reached the per-user device cap (Apple allows
|
|
// up to 5 devices per user license).
|
|
//
|
|
// Apple's numeric code for this case has not been stable across iOS releases,
|
|
// so the helper matches by message substring as well. Confirm against Apple's
|
|
// sandbox before locking in the canonical code.
|
|
func IsMaxDevicesPerUserError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var resp *ErrorResponse
|
|
if !errors.As(err, &resp) || resp == nil {
|
|
return false
|
|
}
|
|
// Known candidate code — refine against sandbox results.
|
|
if resp.ErrorNumber == 9622 {
|
|
return true
|
|
}
|
|
msg := strings.ToLower(resp.ErrorMessage)
|
|
if strings.Contains(msg, "maximum number of devices") ||
|
|
strings.Contains(msg, "device limit") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ResponseErrorInfo represents the request-specific information regarding the
|
|
// failure.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/responseerrorinfo
|
|
type ResponseErrorInfo struct {
|
|
Assets []Asset `json:"assets"`
|
|
ClientUserIds []string `json:"clientUserIds"`
|
|
SerialNumbers []string `json:"serialNumbers"`
|
|
}
|
|
|
|
// client is a package-level client (similar to http.DefaultClient) so it can
|
|
// be reused instead of created as needed, as the internal Transport typically
|
|
// has internal state (cached connections, etc) and it's safe for concurrent
|
|
// use.
|
|
var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
|
|
|
|
// ClientConfig is the subset of Apple's /client/config response that Fleet
|
|
// uses. CountryCode is the lowercase ISO 3166-1 alpha-2 code (e.g. "us",
|
|
// "de") of the storefront associated with the token.
|
|
type ClientConfig struct {
|
|
LocationName string
|
|
CountryCode string
|
|
}
|
|
|
|
// GetConfig fetches the VPP config from Apple's VPP API. This doubles as a
|
|
// verification that the user-provided VPP token is valid. The call is wrapped
|
|
// in a 3-attempt retry; the returned country code is lowercased. ctx bounds
|
|
// the entire retry sequence so callers can cap user-facing latency with a
|
|
// deadline.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/client_config-a40
|
|
func GetConfig(ctx context.Context, token string) (ClientConfig, error) {
|
|
var cfg ClientConfig
|
|
var returnErr error
|
|
|
|
_ = retry.Do(func() error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, getBaseURL()+"/client/config", nil)
|
|
if err != nil {
|
|
returnErr = ctxerr.Wrap(ctx, err, "creating request to Apple VPP endpoint")
|
|
// don't retry on request construction errors
|
|
return nil
|
|
}
|
|
|
|
// Apple's /client/config response uses countryISO2ACode for the ISO
|
|
// 3166-1 alpha-2 storefront country (e.g. "US", "DE"). Verified
|
|
// empirically — the developer docs aren't loaded here.
|
|
var respJSON struct {
|
|
LocationName string `json:"locationName"`
|
|
CountryISO2ACode string `json:"countryISO2ACode"`
|
|
}
|
|
|
|
if err := do(req, token, &respJSON); err != nil {
|
|
returnErr = ctxerr.Wrap(ctx, err, "making request to Apple VPP endpoint")
|
|
|
|
// Don't retry on Apple application errors (e.g. invalid token);
|
|
// only on transient transport-level failures.
|
|
var appleErr *ErrorResponse
|
|
if errors.As(err, &appleErr) {
|
|
return nil
|
|
}
|
|
|
|
// retry on other errors
|
|
return err
|
|
}
|
|
|
|
cfg = ClientConfig{
|
|
LocationName: respJSON.LocationName,
|
|
CountryCode: strings.ToLower(respJSON.CountryISO2ACode),
|
|
}
|
|
returnErr = nil
|
|
return nil
|
|
},
|
|
retry.WithBackoffMultiplier(2),
|
|
retry.WithInterval(500*time.Millisecond),
|
|
retry.WithMaxAttempts(3),
|
|
)
|
|
|
|
return cfg, returnErr
|
|
}
|
|
|
|
// AssociateAssetsRequest is the request for asset management.
|
|
//
|
|
// Apple accepts EITHER SerialNumbers OR ClientUserIds, never both — see Validate.
|
|
type AssociateAssetsRequest struct {
|
|
// Assets are the assets to assign.
|
|
Assets []Asset `json:"assets"`
|
|
// SerialNumbers is the set of identifiers for devices to assign the
|
|
// assets to. Used for device-scoped licensing on manually-enrolled and
|
|
// DEP-enrolled hosts.
|
|
SerialNumbers []string `json:"serialNumbers,omitempty"`
|
|
// ClientUserIds is the set of Fleet-generated identifiers for VPP users
|
|
// (registered via CreateUsers) to assign the assets to. Used for
|
|
// user-scoped licensing on Account-Driven User Enrolled (BYOD) hosts.
|
|
ClientUserIds []string `json:"clientUserIds,omitempty"`
|
|
}
|
|
|
|
// Validate enforces Apple's contract that exactly one of SerialNumbers or
|
|
// ClientUserIds is set on an associate-assets request.
|
|
func (r *AssociateAssetsRequest) Validate() error {
|
|
if r == nil {
|
|
return errors.New("AssociateAssetsRequest: params cannot be nil")
|
|
}
|
|
hasSerials := len(r.SerialNumbers) > 0
|
|
hasUsers := len(r.ClientUserIds) > 0
|
|
switch {
|
|
case hasSerials && hasUsers:
|
|
return errors.New("AssociateAssetsRequest: SerialNumbers and ClientUserIds are mutually exclusive")
|
|
case !hasSerials && !hasUsers:
|
|
return errors.New("AssociateAssetsRequest: one of SerialNumbers or ClientUserIds is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AssociateAssets associates assets to serial numbers or client user IDs
|
|
// according to the request parameters provided.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/associate_assets
|
|
func AssociateAssets(ctx context.Context, token string, params *AssociateAssetsRequest) (string, error) {
|
|
if err := params.Validate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var reqBody bytes.Buffer
|
|
if err := json.NewEncoder(&reqBody).Encode(params); err != nil {
|
|
return "", fmt.Errorf("encoding params as JSON: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, getBaseURL()+"/assets/associate", &reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
req.Header.Add("Content-Type", "application/json")
|
|
|
|
var respBody struct {
|
|
EventID string `json:"eventId"`
|
|
}
|
|
|
|
if err := do(req, token, &respBody); err != nil {
|
|
return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
return respBody.EventID, nil
|
|
}
|
|
|
|
// DisassociateAssetsRequest is the body for Apple's disassociate-assets
|
|
// endpoint. The shape is identical to AssociateAssetsRequest (same assets +
|
|
// mutually-exclusive serialNumbers/clientUserIds contract), so it shares the
|
|
// type and Validate.
|
|
type DisassociateAssetsRequest = AssociateAssetsRequest
|
|
|
|
// DisassociateAssets releases (un-reserves) assets previously associated to
|
|
// serial numbers or client user IDs, returning the reserved seats to the
|
|
// VPP location. Use this to avoid leaking a license when an install that
|
|
// reserved a seat does not proceed (e.g. the activation failed or was
|
|
// cancelled before reaching the device).
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/disassociate_assets
|
|
func DisassociateAssets(token string, params *DisassociateAssetsRequest) (string, error) {
|
|
if err := params.Validate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var reqBody bytes.Buffer
|
|
if err := json.NewEncoder(&reqBody).Encode(params); err != nil {
|
|
return "", fmt.Errorf("encoding params as JSON: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, getBaseURL()+"/assets/disassociate", &reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
req.Header.Add("Content-Type", "application/json")
|
|
|
|
var respBody struct {
|
|
EventID string `json:"eventId"`
|
|
}
|
|
|
|
if err := do(req, token, &respBody); err != nil {
|
|
return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
return respBody.EventID, nil
|
|
}
|
|
|
|
// RegisterUserResponse mirrors Apple's synchronous v1 register-user response.
|
|
//
|
|
// Apple's v1 API responds synchronously with the full user record on success.
|
|
// Status is 0 on success and -1 on error; the optional Error* fields carry
|
|
// the application-level failure reason. UserID is Apple's assigned identifier.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/registervppuserresponse
|
|
type RegisterUserResponse struct {
|
|
Status int `json:"status"`
|
|
ErrorNumber int32 `json:"errorNumber,omitempty"`
|
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
|
User *struct {
|
|
UserID json.Number `json:"userId"`
|
|
Status string `json:"status"`
|
|
ClientUserIDStr string `json:"clientUserIdStr"`
|
|
ManagedAppleIDStr string `json:"managedAppleIDStr"`
|
|
Email string `json:"email,omitempty"`
|
|
InviteURL string `json:"inviteUrl,omitempty"`
|
|
InviteCode string `json:"inviteCode,omitempty"`
|
|
} `json:"user,omitempty"`
|
|
}
|
|
|
|
// RegisterUser registers a single VPP user via Apple's legacy synchronous v1
|
|
// register-user endpoint. Use this rather than the v2 /users/create flow when
|
|
// the caller needs definitive confirmation that registration succeeded before
|
|
// proceeding — v1 returns the full user record (with Apple's assigned userId)
|
|
// in the same response, whereas v2 only returns an eventId that must be
|
|
// polled separately.
|
|
//
|
|
// On any Apple-level failure (status != 0 or non-2xx with error payload),
|
|
// returns an *ErrorResponse so callers can distinguish known error codes
|
|
// (e.g. invalid Managed Apple ID).
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/registervppuserrequest
|
|
func RegisterUser(ctx context.Context, token, clientUserID, managedAppleID string) (string, error) {
|
|
if clientUserID == "" || managedAppleID == "" {
|
|
return "", errors.New("RegisterUser: clientUserId and managedAppleId are required")
|
|
}
|
|
|
|
// v1 takes the VPP server token in the body rather than the
|
|
// Authorization header. Apple keys the user record on email, so we send
|
|
// the Managed Apple ID as both managedAppleIDStr and email.
|
|
reqParams := struct {
|
|
SToken string `json:"sToken"`
|
|
ClientUserIDStr string `json:"clientUserIdStr"`
|
|
ManagedAppleIDStr string `json:"managedAppleIDStr"`
|
|
Email string `json:"email"`
|
|
}{
|
|
SToken: token,
|
|
ClientUserIDStr: clientUserID,
|
|
ManagedAppleIDStr: managedAppleID,
|
|
Email: managedAppleID,
|
|
}
|
|
|
|
var reqBody bytes.Buffer
|
|
if err := json.NewEncoder(&reqBody).Encode(reqParams); err != nil {
|
|
return "", fmt.Errorf("encoding params as JSON: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, getV1BaseURL()+"/registerVPPUserSrv", &reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
|
}
|
|
req.Header.Add("Content-Type", "application/json")
|
|
|
|
var resp RegisterUserResponse
|
|
if err := do(req, "", &resp); err != nil {
|
|
return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
// v1 reports application-level failures via status == -1 with an
|
|
// errorMessage/errorNumber but a 200 transport. Surface those through the
|
|
// same *ErrorResponse callers already handle for v2.
|
|
if resp.Status != 0 || resp.ErrorNumber != 0 || resp.ErrorMessage != "" {
|
|
return "", &ErrorResponse{
|
|
ErrorMessage: resp.ErrorMessage,
|
|
ErrorNumber: resp.ErrorNumber,
|
|
}
|
|
}
|
|
if resp.User == nil || resp.User.UserID == "" {
|
|
return "", errors.New("Apple VPP register-user returned no user record on success")
|
|
}
|
|
|
|
return resp.User.UserID.String(), nil
|
|
}
|
|
|
|
// AssetFilter represents the filters for querying assets.
|
|
type AssetFilter struct {
|
|
// PageIndex is the requested page index.
|
|
PageIndex int32 `json:"pageIndex"`
|
|
|
|
// ProductType is the filter for the asset product type.
|
|
// Possible Values: App, Book
|
|
ProductType string `json:"productType"`
|
|
|
|
// PricingParam is the filter for the asset product quality.
|
|
// Possible Values: STDQ, PLUS
|
|
PricingParam string `json:"pricingParam"`
|
|
|
|
// Revocable is the filter for asset revocability.
|
|
Revocable *bool `json:"revocable"`
|
|
|
|
// DeviceAssignable is the filter for asset device assignability.
|
|
DeviceAssignable *bool `json:"deviceAssignable"`
|
|
|
|
// MaxAvailableCount is the filter for the maximum inclusive assets available count.
|
|
MaxAvailableCount int32 `json:"maxAvailableCount"`
|
|
|
|
// MinAvailableCount is the filter for the minimum inclusive assets available count.
|
|
MinAvailableCount int32 `json:"minAvailableCount"`
|
|
|
|
// MaxAssignedCount is the filter for the maximum inclusive assets assigned count.
|
|
MaxAssignedCount int32 `json:"maxAssignedCount"`
|
|
|
|
// MinAssignedCount is the filter for the minimum inclusive assets assigned count.
|
|
MinAssignedCount int32 `json:"minAssignedCount"`
|
|
|
|
// AdamID is the filter for the asset product unique identifier.
|
|
AdamID string `json:"adamId"`
|
|
}
|
|
|
|
// GetAssets fetches the assets from Apple's VPP API with optional filters.
|
|
func GetAssets(ctx context.Context, token string, filter *AssetFilter) ([]Asset, error) {
|
|
var assets []Asset
|
|
var returnErr error
|
|
|
|
_ = retry.Do(func() error {
|
|
var err error
|
|
assets, err = getAssetsOnce(ctx, token, filter)
|
|
returnErr = err
|
|
|
|
var ne net.Error
|
|
// if we still have some time left on the current request's context
|
|
// deadline and the error is a timeout, we may retry
|
|
if dl, _ := ctx.Deadline(); (dl.IsZero() || time.Until(dl) >= time.Second) && errors.As(err, &ne) && ne.Timeout() {
|
|
// will retry
|
|
return err
|
|
}
|
|
// returnErr may be != nil, but it's not an error that we should retry
|
|
return nil
|
|
},
|
|
retry.WithBackoffMultiplier(3),
|
|
retry.WithInterval(100*time.Millisecond),
|
|
retry.WithMaxAttempts(3),
|
|
)
|
|
return assets, returnErr
|
|
}
|
|
|
|
func getAssetsOnce(ctx context.Context, token string, filter *AssetFilter) ([]Asset, error) {
|
|
baseURL := getBaseURL() + "/assets"
|
|
reqURL, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing base URL: %w", err)
|
|
}
|
|
|
|
if filter != nil {
|
|
query := url.Values{}
|
|
addFilter(query, "adamId", filter.AdamID)
|
|
addFilter(query, "pricingParam", filter.PricingParam)
|
|
addFilter(query, "productType", filter.ProductType)
|
|
addFilter(query, "revocable", filter.Revocable)
|
|
addFilter(query, "deviceAssignable", filter.DeviceAssignable)
|
|
addFilter(query, "maxAvailableCount", filter.MaxAvailableCount)
|
|
addFilter(query, "minAvailableCount", filter.MinAvailableCount)
|
|
addFilter(query, "maxAssignedCount", filter.MaxAssignedCount)
|
|
addFilter(query, "minAssignedCount", filter.MinAssignedCount)
|
|
addFilter(query, "pageIndex", filter.PageIndex)
|
|
reqURL.RawQuery = query.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
var bodyResp struct {
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
if err = do(req, token, &bodyResp); err != nil {
|
|
return nil, fmt.Errorf("retrieving assets: %w", err)
|
|
}
|
|
|
|
return bodyResp.Assets, nil
|
|
}
|
|
|
|
// AssignmentFilter is a representation of the query params for the Apple "Get Assignments"
|
|
// endpoint.
|
|
// https://developer.apple.com/documentation/devicemanagement/get_assignments-o3j#query-parameters
|
|
type AssignmentFilter struct {
|
|
// The filter for the assignment product's unique identifier.
|
|
AdamID string `json:"adamId"`
|
|
// The filter for the unique identifier of assigned users in your organization.
|
|
ClientUserID string `json:"clientUserId"`
|
|
// The requested page index.
|
|
PageIndex int `json:"pageIndex"`
|
|
// The filter for the unique identifier of assigned devices in your organization.
|
|
SerialNumber string `json:"serialNumber"`
|
|
// The filter for modified assignments since the specified version identifier.
|
|
SinceVersionID string `json:"sinceVersionId"`
|
|
}
|
|
|
|
// Assignment represents an asset assignment for a device.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/assignment
|
|
type Assignment struct {
|
|
// The unique identifier for a product in the store.
|
|
AdamID string `json:"adamId"`
|
|
// PricingParam is the quality of a product in the store.
|
|
// Possible Values are `STDQ` and `PLUS`
|
|
PricingParam string `json:"pricingParam"`
|
|
// The unique identifier for a device.
|
|
SerialNumber string `json:"serialNumber"`
|
|
}
|
|
|
|
// GetAssignments fetches the assets from Apple's VPP API with optional filters.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/get_assignments-o3j
|
|
func GetAssignments(ctx context.Context, token string, filter *AssignmentFilter) ([]Assignment, error) {
|
|
baseURL := getBaseURL() + "/assignments"
|
|
reqURL, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing base URL: %w", err)
|
|
}
|
|
|
|
if filter != nil {
|
|
query := url.Values{}
|
|
addFilter(query, "adamId", filter.AdamID)
|
|
addFilter(query, "clientUserId", filter.ClientUserID)
|
|
addFilter(query, "serialNumber", filter.SerialNumber)
|
|
addFilter(query, "sinceVersionId", filter.SinceVersionID)
|
|
addFilter(query, "pageIndex", filter.PageIndex)
|
|
reqURL.RawQuery = query.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
// TODO(roberto): when we get to importing assets assigned by other
|
|
// MDMs we'll need other top-level keys in this struct, and to modify
|
|
// the return value of this function.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/getassignmentsresponse
|
|
var bodyResp struct {
|
|
Assignments []Assignment `json:"assignments"`
|
|
}
|
|
|
|
if err = do(req, token, &bodyResp); err != nil {
|
|
return nil, fmt.Errorf("retrieving assignments: %w", err)
|
|
}
|
|
|
|
return bodyResp.Assignments, nil
|
|
}
|
|
|
|
const (
|
|
errorNumberTooManyRequests int32 = 9646
|
|
)
|
|
|
|
var (
|
|
// vppMaxAttempts is the initial attempt plus retries (4 = 1 + 3).
|
|
vppMaxAttempts = 4
|
|
maxVPPBackoff = 30 * time.Second
|
|
// Backoff for the "too many requests" (rate-limited) case, which has no
|
|
// Apple-provided Retry-After to honor.
|
|
vppRateLimitInterval = 5 * time.Second
|
|
vppRateLimitBackoffMultiplier = 3
|
|
)
|
|
|
|
func do[T any](req *http.Request, token string, dest *T) error {
|
|
// v1 endpoints carry the token in the request body, so callers pass an
|
|
// empty string to skip the Authorization header.
|
|
if token != "" {
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
}
|
|
|
|
ctx := req.Context()
|
|
var lastReason string
|
|
|
|
for attempt := 0; attempt < vppMaxAttempts; attempt++ {
|
|
// Reset the request body for each attempt. After client.Do reads the
|
|
// body it's consumed; GetBody (set by http.NewRequest for *bytes.Buffer)
|
|
// returns a fresh reader over the original bytes.
|
|
if req.GetBody != nil {
|
|
body, err := req.GetBody()
|
|
if err != nil {
|
|
return fmt.Errorf("resetting request body for VPP retry: %w", err)
|
|
}
|
|
req.Body = body
|
|
}
|
|
|
|
done, retryAfter, err := doVPPAttempt(req, dest)
|
|
if done {
|
|
// Terminal outcome: success (err == nil, dest populated) or a
|
|
// non-retryable error.
|
|
return err
|
|
}
|
|
|
|
// Record why we're retrying for the eventual "exhausted" error.
|
|
if retryAfter > 0 {
|
|
lastReason = fmt.Sprintf("HTTP 500 with Retry-After %s", retryAfter)
|
|
} else {
|
|
lastReason = "rate limited (too many requests)"
|
|
}
|
|
|
|
// No need to back off after the final attempt — we're about to give up.
|
|
if attempt == vppMaxAttempts-1 {
|
|
break
|
|
}
|
|
|
|
// Compute the capped wait. For HTTP 500 we honor Apple's Retry-After
|
|
// (capped); for the rate-limited case Apple gives no wait, so we use our
|
|
// own incremental backoff.
|
|
wait := retryAfter
|
|
if wait <= 0 {
|
|
wait = vppRateLimitInterval
|
|
for i := 0; i < attempt; i++ {
|
|
wait *= time.Duration(vppRateLimitBackoffMultiplier)
|
|
}
|
|
}
|
|
if wait > maxVPPBackoff {
|
|
wait = maxVPPBackoff
|
|
}
|
|
if err := sleepContext(ctx, wait); err != nil {
|
|
return fmt.Errorf("waiting to retry Apple VPP endpoint: %w", err)
|
|
}
|
|
}
|
|
|
|
return &ErrorResponse{
|
|
ErrorMessage: fmt.Sprintf("gave up after %d attempts (last: %s)", vppMaxAttempts, lastReason),
|
|
}
|
|
}
|
|
|
|
func doVPPAttempt[T any](req *http.Request, dest *T) (done bool, retryAfter time.Duration, err error) {
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return true, 0, fmt.Errorf("making request to Apple VPP endpoint: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return true, 0, fmt.Errorf("reading response body from Apple VPP endpoint: %w", err)
|
|
}
|
|
|
|
// For an HTTP 500 server error response, a Retry-After header indicates how
|
|
// long the client must wait before making additional requests.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3742679
|
|
if ra := resp.Header.Get("Retry-After"); resp.StatusCode == http.StatusInternalServerError && ra != "" {
|
|
seconds, perr := strconv.ParseInt(ra, 10, 0)
|
|
if perr != nil {
|
|
return true, 0, fmt.Errorf("parsing retry-after header: %w", perr)
|
|
}
|
|
// Clamp to our backoff cap before scaling to a Duration: do() caps the
|
|
// wait at maxVPPBackoff anyway, and converting an absurdly large value
|
|
// would overflow the int64 Duration math (potentially wrapping negative).
|
|
// Non-positive values are left as-is and fall through to do()'s default
|
|
// backoff.
|
|
if maxSeconds := int64(maxVPPBackoff / time.Second); seconds > maxSeconds {
|
|
seconds = maxSeconds
|
|
}
|
|
return false, time.Duration(seconds) * time.Second, nil
|
|
}
|
|
|
|
// For some reason, Apple returns 200 OK even if you pass an invalid token in the Auth header.
|
|
// We will need to parse the response and check to see if it contains an error.
|
|
var errResp ErrorResponse
|
|
if uerr := json.Unmarshal(body, &errResp); uerr == nil && (errResp.ErrorMessage != "" || errResp.ErrorNumber != 0) {
|
|
// Too many requests for the current Organization: the request was
|
|
// rejected due to high server volume or an MDM issue. Retry with
|
|
// incremental backoff.
|
|
//
|
|
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3783126
|
|
if errResp.ErrorNumber == errorNumberTooManyRequests {
|
|
return false, 0, nil
|
|
}
|
|
return true, 0, &errResp
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
limitedBody := body
|
|
if len(limitedBody) > 1000 {
|
|
limitedBody = limitedBody[:1000]
|
|
}
|
|
return true, 0, fmt.Errorf("calling Apple VPP endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody))
|
|
}
|
|
|
|
if dest != nil {
|
|
if jerr := json.Unmarshal(body, dest); jerr != nil {
|
|
return true, 0, fmt.Errorf("decoding response data from Apple VPP endpoint: %w", jerr)
|
|
}
|
|
}
|
|
|
|
return true, 0, nil
|
|
}
|
|
|
|
// sleepContext waits for d to elapse or for ctx to be done, whichever comes
|
|
// first, returning ctx.Err() if the context is canceled before the wait elapses.
|
|
func sleepContext(ctx context.Context, d time.Duration) error {
|
|
if d <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func getBaseURL() string {
|
|
devURL := dev_mode.Env("FLEET_DEV_VPP_URL")
|
|
if devURL != "" {
|
|
return devURL
|
|
}
|
|
return "https://vpp.itunes.apple.com/mdm/v2"
|
|
}
|
|
|
|
// getV1BaseURL returns the base URL for Apple's legacy v1 VPP endpoints. The
|
|
// dev override (FLEET_DEV_VPP_URL) is returned as-is so tests can mock both
|
|
// v1 and v2 against the same httptest server using path-based routing.
|
|
func getV1BaseURL() string {
|
|
devURL := dev_mode.Env("FLEET_DEV_VPP_URL")
|
|
if devURL != "" {
|
|
return devURL
|
|
}
|
|
return "https://vpp.itunes.apple.com/mdm"
|
|
}
|
|
|
|
// addFilter adds a filter to the query values if it is not the zero value.
|
|
func addFilter(query url.Values, key string, value any) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if v != "" {
|
|
query.Add(key, v)
|
|
}
|
|
case *bool:
|
|
if v != nil {
|
|
query.Add(key, strconv.FormatBool(*v))
|
|
}
|
|
case int32:
|
|
if v != 0 {
|
|
query.Add(key, fmt.Sprintf("%d", v))
|
|
}
|
|
}
|
|
}
|