diff --git a/changes/26114-new-team-with-vpp-apps b/changes/26114-new-team-with-vpp-apps new file mode 100644 index 0000000000..6a0233d03f --- /dev/null +++ b/changes/26114-new-team-with-vpp-apps @@ -0,0 +1 @@ +Fixed `fleetctl gitops` issue where creating a new team containing VPP apps caused an error. diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index 2af554fde1..a4e7584a61 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -15,7 +15,10 @@ import ( "golang.org/x/text/unicode/norm" ) -const filenameMaxLength = 255 +const ( + filenameMaxLength = 255 + reapplyingTeamForVPPAppsMsg = "[!] re-applying configs for team %s -- this only happens once for new teams that have VPP apps\n" +) type LabelUsage struct { Name string @@ -96,8 +99,14 @@ func gitopsCommand() *cli.Command { var originalVPPConfig []any var teamNames []string var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions - var abmTeams, vppTeams []string - var hasMissingABMTeam, hasMissingVPPTeam, usesLegacyABMConfig bool + var abmTeams, vppTeams, missingVPPTeams []string + var hasMissingABMTeam, usesLegacyABMConfig bool + type missingVPPTeamWithApps struct { + config *spec.GitOps + vppApps []*fleet.TeamSpecAppStoreApp + filename string + } + var missingVPPTeamsWithApps []missingVPPTeamWithApps // we keep track of team software installers and scripts for correct policy application teamsSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse) @@ -233,7 +242,7 @@ func gitopsCommand() *cli.Command { return err } - vppTeams, hasMissingVPPTeam, err = checkVPPTeamAssignments(config, fleetClient) + vppTeams, missingVPPTeams, err = checkVPPTeamAssignments(config, fleetClient) if err != nil { return err } @@ -258,7 +267,7 @@ func gitopsCommand() *cli.Command { } } - if hasMissingVPPTeam { + if len(missingVPPTeams) > 0 { if mdm, ok := config.OrgSettings["mdm"]; ok { if mdmMap, ok := mdm.(map[string]any); ok { if vpp, ok := mdmMap["volume_purchasing_program"]; ok { @@ -275,6 +284,21 @@ 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. + 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 flDryRun { incomingSecrets := fleetClient.GetGitOpsSecrets(config) for _, secret := range incomingSecrets { @@ -308,11 +332,23 @@ func gitopsCommand() *cli.Command { return err } } - if len(vppTeams) > 0 && hasMissingVPPTeam { + if len(missingVPPTeams) > 0 { if err = applyVPPTokenAssignmentIfNeeded(c, teamNames, vppTeams, originalVPPConfig, flDryRun, fleetClient); err != nil { return err } } + // Now that VPP tokens have been assigned, we can apply VPP apps to the new team. + // For simplicity, we simply re-apply the entire config. This only happens once when the team is created. + for _, teamWithApps := range missingVPPTeamsWithApps { + _, _ = fmt.Fprintf(c.App.Writer, reapplyingTeamForVPPAppsMsg, *teamWithApps.config.TeamName) + teamWithApps.config.Software.AppStoreApps = teamWithApps.vppApps + _, err := fleetClient.DoGitOps(c.Context, teamWithApps.config, teamWithApps.filename, logf, flDryRun, teamDryRunAssumptions, appConfig, + teamsSoftwareInstallers, teamsVPPApps, teamsScripts) + if err != nil { + return err + } + } + if flDeleteOtherTeams && appConfig.License.IsPremium() { // skip team deletion for non-premium users teams, err := fleetClient.ListTeams("") if err != nil { @@ -621,13 +657,13 @@ func applyABMTokenAssignmentIfNeeded( } func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( - vppTeams []string, missingTeam bool, err error, + vppTeams []string, missingTeams []string, err error, ) { if mdm, ok := config.OrgSettings["mdm"]; ok { if mdmMap, ok := mdm.(map[string]any); ok { teams, err := fleetClient.ListTeams("") if err != nil { - return nil, false, err + return nil, nil, err } teamNames := map[string]struct{}{} for _, tm := range teams { @@ -645,7 +681,7 @@ func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( normalizedTeam := norm.NFC.String(teamStr) vppTeams = append(vppTeams, normalizedTeam) if _, ok := teamNames[normalizedTeam]; !ok { - missingTeam = true + missingTeams = append(missingTeams, normalizedTeam) } } } @@ -657,7 +693,7 @@ func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( } } - return vppTeams, missingTeam, nil + return vppTeams, missingTeams, nil } func applyVPPTokenAssignmentIfNeeded( diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index f9a61f861d..6dd3d54f29 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "encoding/json" "fmt" "net/http" @@ -2785,6 +2786,60 @@ func TestGitOpsTeamVPPApps(t *testing.T) { } } +// TestGitOpsTeamVPPAndApp tests the flow where a new team is created with VPP apps. +// GitOps must first create the team, then assign VPP token to it, and only then add VPP apps. +func TestGitOpsTeamVPPAndApp(t *testing.T) { + startAndServeVPPServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + renewDate := time.Now().Add(24 * time.Hour) + token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca") + require.NoError(t, err) + + 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 + } + + // The following mocks are key to this test. + 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 + } + + buf, err := runAppNoChecks([]string{"gitops", "-f", "testdata/gitops/global_config_vpp.yml", "-f", "testdata/gitops/team_vpp_valid_app.yml"}) + require.NoError(t, err) + assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked) + assert.True(t, ds.GetVPPTokenByTeamIDFuncInvoked) + assert.True(t, ds.SetTeamVPPAppsFuncInvoked) + assert.Contains(t, buf.String(), fmt.Sprintf(reapplyingTeamForVPPAppsMsg, teamName)) +} + func TestGitOpsCustomSettings(t *testing.T) { cases := []struct { file string @@ -3185,6 +3240,17 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, } return nil, nil } + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + summary := make([]*fleet.TeamSummary, 0, len(savedTeams)) + for _, team := range savedTeams { + summary = append(summary, &fleet.TeamSummary{ + ID: (*team).ID, + Name: (*team).Name, + Description: (*team).Description, + }) + } + return summary, nil + } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { return nil, 0, nil, nil } diff --git a/cmd/fleetctl/testdata/gitops/global_config_vpp.yml b/cmd/fleetctl/testdata/gitops/global_config_vpp.yml new file mode 100644 index 0000000000..0d5c9c9d39 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/global_config_vpp.yml @@ -0,0 +1,16 @@ +policies: +queries: +agent_options: +controls: +org_settings: + mdm: + volume_purchasing_program: + - location: Earth + teams: + - "${TEST_TEAM_NAME}" + server_settings: + server_url: https://example.com + org_info: + org_name: Fleet + secrets: + - secret: "FLEET_GLOBAL_ENROLL_SECRET"