Defer all VPP apps when there are missing teams (#42862)

Fixes #40785

## Summary

When a GitOps run includes a `volume_purchasing_program` config that
references a team that doesn't exist yet, the code temporarily removes
the entire VPP config from the global AppConfig, clearing ALL VPP
token-to-team assignments on the server. However, the code only deferred
`app_store_apps` for the missing teams, not for existing teams that also
lost their VPP assignments. Those existing teams then failed with "No
available VPP Token" when their `app_store_apps` were applied.

The fix widens the deferral scope to match the clearing scope. When VPP
assignments are temporarily cleared, `app_store_apps` are now deferred
for all teams in the VPP config, not just the missing ones.
This commit is contained in:
Carlo 2026-04-02 15:38:58 -04:00 committed by GitHub
parent e1f93cb28d
commit aa0c0674a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 12 deletions

View file

@ -0,0 +1 @@
- Fixed `fleetctl gitops` failing with "No available VPP Token" when assigning VPP apps alongside a new team.

View file

@ -19,7 +19,7 @@ import (
const (
filenameMaxLength = 255
ReapplyingTeamForVPPAppsMsg = "[!] re-applying configs for team %s -- this only happens once for new teams that have VPP apps\n"
ReapplyingTeamForVPPAppsMsg = "[!] re-applying configs for team %s to set VPP apps\n"
)
type LabelUsage struct {
@ -518,18 +518,19 @@ func gitopsCommand() *cli.Command {
}
}
// We cannot apply a VPP app to a new team until that team gets a VPP token.
// So, we create the team, then apply the VPP token, then apply VPP apps.
// Teams need a VPP token before VPP apps can be applied. When some VPP
// teams don't exist yet, the VPP config is temporarily removed from the
// global config, which clears all VPP token assignments. To avoid
// "No available VPP Token" errors, we defer app_store_apps for every
// team in the VPP config and re-apply them after tokens are reassigned.
if !isGlobalConfig && len(missingVPPTeams) > 0 && len(config.Software.AppStoreApps) > 0 {
for _, missingTeam := range missingVPPTeams {
if missingTeam == *config.TeamName {
missingVPPTeamsWithApps = append(missingVPPTeamsWithApps, missingVPPTeamWithApps{
config: config,
vppApps: config.Software.AppStoreApps,
filename: flFilename,
})
config.Software.AppStoreApps = nil
}
if slices.Contains(vppTeams, *config.TeamName) {
missingVPPTeamsWithApps = append(missingVPPTeamsWithApps, missingVPPTeamWithApps{
config: config,
vppApps: config.Software.AppStoreApps,
filename: flFilename,
})
config.Software.AppStoreApps = nil
}
}

View file

@ -705,6 +705,147 @@ func TestGitOpsTeamVPPAndApp(t *testing.T) {
assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, teamName))
}
// TestGitOpsExistingTeamVPPAppsWithMissingTeam tests the scenario where:
// - An existing team with app_store_apps is in the VPP config
// - A NEW team (doesn't exist yet) is also in the VPP config
// When there are missing VPP teams, the VPP config is temporarily cleared,
// which removes VPP assignments for ALL teams. We must defer app_store_apps
// for all VPP teams, not just missing ones. (Issue #40785)
func TestGitOpsExistingTeamVPPAppsWithMissingTeam(t *testing.T) {
testing_utils.StartAndServeVPPServer(t)
ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t)
renewDate := time.Now().Add(24 * time.Hour)
token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca")
require.NoError(t, err)
existingTeamName := "Existing Team"
newTeamName := "New Team"
// Pre-populate the existing team so checkVPPTeamAssignments sees it.
existingTeam := &fleet.Team{ID: 42, Name: existingTeamName}
savedTeams[existingTeamName] = &existingTeam
// No existing labels — this test doesn't test label behavior.
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return nil, nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) {
return 0, nil
}
ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {
return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil
}
ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) {
return nil, nil
}
vppToken := &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: renewDate,
Token: string(token),
Teams: nil,
}
tokensByTeams := make(map[uint]*fleet.VPPTokenDB)
ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) {
for _, teamID := range teams {
tokensByTeams[teamID] = vppToken
}
return vppToken, nil
}
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{vppToken}, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
if teamID == nil {
return vppToken, nil
}
token, ok := tokensByTeams[*teamID]
if !ok {
return nil, sql.ErrNoRows
}
return token, nil
}
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
return []uint{}, nil
}
ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error {
return nil
}
ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error {
return nil
}
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{}, nil
}
globalCfg := fmt.Sprintf(`
policies:
queries:
agent_options:
controls:
org_settings:
mdm:
volume_purchasing_program:
- location: Earth
teams:
- %q
- %q
server_settings:
server_url: https://example.com
org_info:
org_name: Fleet
secrets:
- secret: "FLEET_GLOBAL_ENROLL_SECRET"
`, existingTeamName, newTeamName)
teamCfg := func(name string) string {
return fmt.Sprintf(`
name: %q
team_settings:
secrets:
- secret: "%s-secret"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
app_store_apps:
- app_store_id: "1"
`, name, name)
}
tmpDir := t.TempDir()
globalFile := filepath.Join(tmpDir, "default.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(globalCfg), 0o644))
existingTeamFile := filepath.Join(tmpDir, "existing-team.yml")
require.NoError(t, os.WriteFile(existingTeamFile, []byte(teamCfg(existingTeamName)), 0o644))
newTeamFile := filepath.Join(tmpDir, "new-team.yml")
require.NoError(t, os.WriteFile(newTeamFile, []byte(teamCfg(newTeamName)), 0o644))
buf, err := fleetctl.RunAppNoChecks([]string{
"gitops", "-f", globalFile, "-f", existingTeamFile, "-f", newTeamFile,
})
require.NoError(t, err)
assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked)
assert.True(t, ds.GetVPPTokenByTeamIDFuncInvoked)
assert.True(t, ds.SetTeamVPPAppsFuncInvoked)
// Both teams should have had their VPP apps deferred and re-applied.
assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, existingTeamName))
assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, newTeamName))
}
func TestGitOpsVPP(t *testing.T) {
global := func(mdm string) string {
return fmt.Sprintf(`