Files
Jonathan Katz 45abf8c9ad Add software installer upload/download progress to GitOps runs (#50250)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45728
Changes:
- Adds a new redis key to keep track of downloaded packages. It starts
out with an empty list and gets filled with each download. Each update
writes the entire struct at once to the key.
- Adds logging in the fleetctl gitops client to show which packages were
downloaded
- Fixes the categories key potentially expiring 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
-  Timeouts are implemented and retries are limited to avoid infinite
loops
- Right now the batch will write the whole slice of all packages to a
single redis key for every package in the loop. Looks like performance
is acceptable for now (500 packages), but maybe this will need to be
limited.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features
- Added per-package software download progress in fleetctl GitOps.
- Progress now reports downloading, completed, skipped, and failed
packages during real and dry runs.
- Installation output now distinguishes applying and applied stages.

## Bug Fixes
- Improved download error messages and cached-package handling.
- Prevented duplicate progress messages and ensured tracking issues do
not interrupt successful software batches.

## Tests
- Expanded coverage for progress reporting, failures, dry runs, package
types, and authorization scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 21:36:44 -04:00

1681 lines
65 KiB
Go

package gitops
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/fleetctltest"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
teamName = "Team Test"
)
func TestGitOpsTeamSoftwareInstallers(t *testing.T) {
testing_utils.StartSoftwareInstallerServer(t)
testing_utils.StartAndServeVPPServer(t)
cases := []struct {
file string
wantErr string
}{
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/team_software_installer_install_script_secret.yml", "environment variable \"FLEET_SECRET_NAME\" not set"},
{"testdata/gitops/team_software_installer_unsupported.yml", "The file's content doesn't match a supported installer format. Supported types: .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_subdir.yml", ""},
{"testdata/gitops/subdir/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_valid_apply.yml", ""},
{"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
{"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."},
{"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_uninstall_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "at least one of hash_sha256 or url is required for each software package"},
{"testdata/gitops/team_software_installer_no_url_multi.yml", "multi_missing_url.yml, list item #1"},
{
"testdata/gitops/team_software_installer_invalid_self_service_value.yml",
"Couldn't edit \"../../fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml\" at \"software.packages.self_service\", expected type bool but got string",
},
{
"testdata/gitops/team_software_installer_invalid_both_include_exclude.yml",
`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`,
},
{"testdata/gitops/team_software_installer_valid_include.yml", ""},
{"testdata/gitops/team_software_installer_valid_exclude.yml", ""},
{"testdata/gitops/team_software_installer_valid_include_all.yml", ""},
{
"testdata/gitops/team_software_installer_invalid_unknown_label.yml",
"Please create the missing labels, or update your settings to not refer to these labels.",
},
// display_name tests
{"testdata/gitops/team_software_installer_with_display_name.yml", ""},
{"testdata/gitops/team_software_installer_display_name_too_long.yml", "display_name is too long (max 255 characters)"},
{"testdata/gitops/team_software_app_store_display_name_too_long.yml", "display_name is too long (max 255 characters)"},
// team tests for setup experience software/script
{"testdata/gitops/team_setup_software_valid.yml", ""},
{"testdata/gitops/team_setup_software_on_package.yml", ""},
{"testdata/gitops/team_setup_software_defined_in_conflicting_places.yml", " Setup experience may only be specified directly on software or within macos_setup, but not both."},
{"testdata/gitops/team_setup_software_defined_in_conflicting_places_vpp.yml", " Setup experience may only be specified directly on software or within macos_setup, but not both."},
{"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
{"testdata/gitops/team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that fleet"},
{"testdata/gitops/team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that fleet"},
{"testdata/gitops/team_software_installer_valid_ipa.yml", ""},
{"testdata/gitops/team_software_installer_subdir_ipa.yml", ""},
}
for _, c := range cases {
c.file = filepath.Join("../../fleetctl", c.file)
t.Run(filepath.Base(c.file), func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
tokExpire := time.Now().Add(time.Hour)
token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca")
require.NoError(t, err)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
return false, nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: tokExpire,
Token: string(token),
Teams: nil,
CountryCode: "us",
}, nil
}
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return []*fleet.LabelSpec{
{
Name: "a",
Description: "A global label",
LabelMembershipType: fleet.LabelMembershipTypeManual,
Hosts: []string{"host2", "host3"},
},
{
Name: "b",
Description: "Another label",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
Query: "SELECT 1 from osquery_info",
},
}, nil
}
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"a": 2,
"b": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range names {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
}
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
return nil, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, 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
}
ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error {
return nil
}
ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error {
return nil
}
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) {
testing_utils.StartSoftwareInstallerServer(t)
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
t.Setenv("QUERY_VAR", "IT_WORKS")
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
if len(installers) != 0 && installers[0].PreInstallQuery != "select IT_WORKS" {
return fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery)
}
return nil
}
ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) {
return nil, nil
}
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
}
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
return nil, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, 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
}
_, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml"})
require.NoError(t, err)
}
// gitops --dry-run with software.packages: [] must short-circuit on the server
// and not exercise any of the batch-set or installer-validation datastore paths.
// See ee/server/service/software_installers.go::BatchSetSoftwareInstallers.
func TestGitOpsTeamSoftwareInstallersEmptyPackagesDryRun(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
t.Errorf("BatchSetSoftwareInstallers must not be called for dry-run with empty packages")
return nil
}
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
t.Errorf("GetTeamsWithInstallerByHash must not be called for dry-run with empty packages")
return nil, nil
}
_, err := fleetctltest.RunAppNoChecks([]string{
"gitops", "--dry-run",
"-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_empty_packages.yml",
})
require.NoError(t, err)
}
// gitops must print one message per software package the batch set will delete
// (real run) or would delete (dry run): packages on the server whose title
// matches no entry in the YAML. See #43729.
func TestGitOpsSoftwareDeletionWarnings(t *testing.T) {
ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t)
// Pre-populate the team: with an existing team the empty-packages dry run
// must NOT short-circuit when installers are pending deletion.
tmName := os.Getenv("TEST_TEAM_NAME")
team := &fleet.Team{ID: 123, Name: tmName}
savedTeams[tmName] = &team
var pendingDeletion []fleet.DeletedSoftwarePackage
ds.GetSoftwareInstallersPendingDeletionFunc = func(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) {
// assert (not require): this runs on a server goroutine, where FailNow would misbehave.
assert.Empty(t, incoming) // the YAML has no packages
return pendingDeletion, nil
}
file := "../../fleetctl/testdata/gitops/team_software_installer_valid_empty_packages.yml"
t.Run("dry run reports would-be deletions", func(t *testing.T) {
pendingDeletion = []fleet.DeletedSoftwarePackage{
{TeamID: &team.ID, TitleID: 1, DisplayName: "Cool App"},
{TeamID: &team.ID, TitleID: 2, DisplayName: "Teammate Tool"},
}
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file})
require.NoError(t, err)
require.Contains(t, out.String(), "[-] would've deleted software - Cool App\n")
require.Contains(t, out.String(), "[-] would've deleted software - Teammate Tool\n")
})
t.Run("real run reports deletions", func(t *testing.T) {
pendingDeletion = []fleet.DeletedSoftwarePackage{
{TeamID: &team.ID, TitleID: 1, DisplayName: "Cool App"},
}
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file})
require.NoError(t, err)
require.Contains(t, out.String(), "[-] deleted software - Cool App\n")
})
t.Run("no deletions, no noise", func(t *testing.T) {
pendingDeletion = nil
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file})
require.NoError(t, err)
require.NotContains(t, out.String(), "deleted software")
out, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file})
require.NoError(t, err)
require.NotContains(t, out.String(), "deleted software")
})
}
func TestGitOpsNoTeamVPPPolicies(t *testing.T) {
testing_utils.StartAndServeVPPServer(t)
cases := []struct {
noTeamFile string
wantErr string
vppApps []fleet.VPPAppResponse
}{
{
noTeamFile: "testdata/gitops/subdir/no_team_vpp_policies_valid.yml",
vppApps: []fleet.VPPAppResponse{
{ // for more test coverage
Platform: fleet.MacOSPlatform,
},
{ // for more test coverage
TitleID: ptr.Uint(122),
Platform: fleet.MacOSPlatform,
},
{
TeamID: ptr.Uint(0),
TitleID: ptr.Uint(123),
AppStoreID: "1",
Platform: fleet.IOSPlatform,
},
{
TeamID: ptr.Uint(0),
TitleID: ptr.Uint(124),
AppStoreID: "1",
Platform: fleet.MacOSPlatform,
},
{
TeamID: ptr.Uint(0),
TitleID: ptr.Uint(125),
AppStoreID: "1",
Platform: fleet.IPadOSPlatform,
},
},
},
}
for _, c := range cases {
c.noTeamFile = filepath.Join("../../fleetctl", c.noTeamFile)
t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
tokExpire := time.Now().Add(time.Hour)
token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca")
require.NoError(t, err)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
return false, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return c.vppApps, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: tokExpire,
Token: string(token),
Teams: nil,
CountryCode: "us",
}, nil
}
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"a": 2,
"b": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range names {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) {
return map[string]*fleet.Label{
"a": {
ID: 1,
Name: "a",
},
"b": {
ID: 2,
Name: "b",
},
}, nil
}
ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error {
return nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]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
}
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
globalFile := "../../fleetctl/testdata/gitops/global_config_no_paths.yml"
dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml")
t.Cleanup(func() {
os.Remove(dstPath)
})
err = file.Copy(c.noTeamFile, dstPath, 0o755)
require.NoError(t, err)
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
testing_utils.StartSoftwareInstallerServer(t)
testing_utils.StartAndServeVPPServer(t)
cases := []struct {
noTeamFile string
wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/no_team_software_installer_unsupported.yml", "The file's content doesn't match a supported installer format. Supported types: .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_subdir.yml", ""},
{"testdata/gitops/subdir/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
{"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_uninstall_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_no_url.yml", "at least one of hash_sha256 or url is required for each software package"},
{
"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml",
"Couldn't edit \"../../fleetctl/testdata/gitops/no-team.yml\" at \"software.packages.self_service\", expected type bool but got string",
},
{
"testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml",
`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`,
},
{"testdata/gitops/no_team_software_installer_valid_include.yml", ""},
{"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""},
{"testdata/gitops/no_team_software_installer_valid_include_all.yml", ""},
{
"testdata/gitops/no_team_software_installer_invalid_unknown_label.yml",
"Please create the missing labels, or update your settings to not refer to these labels.",
},
// No team tests for setup experience software/script
{"testdata/gitops/no_team_setup_software_valid.yml", ""},
{"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
{"testdata/gitops/no_team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that fleet"},
{"testdata/gitops/no_team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that fleet"},
{"testdata/gitops/no_team_software_installer_valid_ipa.yml", ""},
{"testdata/gitops/no_team_software_installer_subdir_ipa.yml", ""},
}
for _, c := range cases {
c.noTeamFile = filepath.Join("../../fleetctl", c.noTeamFile)
t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
tokExpire := time.Now().Add(time.Hour)
token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca")
require.NoError(t, err)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
return false, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: tokExpire,
Token: string(token),
Teams: nil,
CountryCode: "us",
}, nil
}
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return []*fleet.LabelSpec{
{
Name: "a",
Description: "A global label",
LabelMembershipType: fleet.LabelMembershipTypeManual,
Hosts: []string{"host2", "host3"},
},
{
Name: "b",
Description: "Another label",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
Query: "SELECT 1 from osquery_info",
},
}, nil
}
ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error {
return nil
}
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"a": 2,
"b": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range names {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
}
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
return nil, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]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
}
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
globalFile := "../../fleetctl/testdata/gitops/global_config_no_paths.yml"
if strings.HasPrefix(filepath.Base(c.noTeamFile), "no_team_setup_software") {
// the controls section is in the no-team test file, so use a global file without that section
globalFile = "../../fleetctl/testdata/gitops/global_config_no_paths_no_controls.yml"
}
dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml")
t.Cleanup(func() {
os.Remove(dstPath)
})
err = file.Copy(c.noTeamFile, dstPath, 0o755)
require.NoError(t, err)
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func TestGitOpsTeamVPPApps(t *testing.T) {
testing_utils.StartAndServeVPPServer(t)
cases := []struct {
file string
wantErr string
tokenExpiration time.Time
expectedLabels map[string]uint
}{
{"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}},
{"testdata/gitops/team_vpp_valid_app_self_service.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}},
{"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}},
{"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour), map[string]uint{}},
{"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour), map[string]uint{}},
{"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour), map[string]uint{}},
{
"testdata/gitops/team_vpp_incorrect_type.yml", "Couldn't edit \"../../fleetctl/testdata/gitops/team_vpp_incorrect_type.yml\" at \"software.app_store_apps.app_store_id\", expected type string but got number",
time.Now().Add(24 * time.Hour),
map[string]uint{},
},
{"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour), map[string]uint{}},
{
"testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml", "", time.Now().Add(24 * time.Hour),
map[string]uint{"label 1": 1, "label 2": 2},
},
{
"testdata/gitops/team_vpp_valid_app_labels_include_any.yml", "", time.Now().Add(24 * time.Hour),
map[string]uint{"label 1": 1, "label 2": 2},
},
{
"testdata/gitops/team_vpp_valid_app_labels_include_all.yml", "", time.Now().Add(24 * time.Hour),
map[string]uint{"label 1": 1, "label 2": 2},
},
{
"testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml",
"Please create the missing labels, or update your settings to not refer to these labels.", time.Now().Add(24 * time.Hour),
map[string]uint{"label 1": 1, "label 2": 2},
},
{
"testdata/gitops/team_vpp_invalid_app_labels_include_any.yml",
"Please create the missing labels, or update your settings to not refer to these labels.", time.Now().Add(24 * time.Hour),
map[string]uint{"label 1": 1, "label 2": 2},
},
{
"testdata/gitops/team_vpp_invalid_app_labels_both.yml",
`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for app store app`, time.Now().Add(24 * time.Hour),
map[string]uint{},
},
}
for _, c := range cases {
c.file = filepath.Join("../../fleetctl", c.file)
t.Run(filepath.Base(c.file), func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
token, err := test.CreateVPPTokenEncoded(c.tokenExpiration, "fleet", "ca")
require.NoError(t, err)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
return false, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: c.tokenExpiration,
Token: string(token),
Teams: nil,
CountryCode: "us",
}, nil
}
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return []*fleet.LabelSpec{
{
Name: "label 1",
Description: "A global label",
LabelMembershipType: fleet.LabelMembershipTypeManual,
Hosts: []string{"host2", "host3"},
},
{
Name: "label 2",
Description: "Another label",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
Query: "SELECT 1 from osquery_info",
},
}, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, nil
}
found := make(map[string]uint)
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
for _, l := range names {
if id, ok := c.expectedLabels[l]; ok {
found[l] = id
}
}
return found, nil
}
ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) {
found2 := make(map[string]*fleet.Label)
for _, l := range names {
if id, ok := c.expectedLabels[l]; ok {
found2[l] = &fleet.Label{
ID: id,
Name: l,
}
}
}
return found2, 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
}
ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error {
return nil
}
ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error {
return nil
}
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
if len(c.expectedLabels) > 0 {
require.True(t, ds.LabelIDsByNameFuncInvoked)
require.True(t, ds.LabelsByNameFuncInvoked)
}
require.Equal(t, c.expectedLabels, found)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
// 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) {
testing_utils.StartAndServeVPPServer(t)
ds, _, _ := testing_utils.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
}
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
}
// The following mocks are key to this test.
vppToken := &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: renewDate,
Token: string(token),
Teams: nil,
CountryCode: "us",
}
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.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]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
}
buf, err := fleetctltest.RunAppNoChecks([]string{
"gitops", "-f", "../../fleetctl/testdata/gitops/global_config_vpp.yml", "-f",
"../../fleetctl/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(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,
CountryCode: "us",
}
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.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]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 := fleetctltest.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))
}
// TestGitOpsNewTeamVPPSharedWithUnsuppliedExistingTeam covers issue #44444: adding
// a NEW team that shares a VPP token with an EXISTING team must succeed even when
// the existing team's config file is NOT supplied in the same run. Detecting a
// missing (new) team strips the VPP config and re-applies it after teams are
// created; that re-apply must validate referenced teams against teams that exist in
// Fleet, not only against teams whose files were supplied this run. Previously this
// errored with "volume_purchasing_program team <existing> not found in team configs".
func TestGitOpsNewTeamVPPSharedWithUnsuppliedExistingTeam(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 it exists in Fleet, but deliberately do NOT
// supply its config file in the gitops run below.
existingTeam := &fleet.Team{ID: 42, Name: existingTeamName}
savedTeams[existingTeamName] = &existingTeam
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, CountryCode: "us",
}
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
}
tok, ok := tokensByTeams[*teamID]
if !ok {
return nil, sql.ErrNoRows
}
return tok, nil
}
ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, teamID uint, 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
fleets:
- %q
- %q
server_settings:
server_url: https://example.com
org_info:
org_name: Fleet
secrets:
- secret: "FLEET_GLOBAL_ENROLL_SECRET"
`, existingTeamName, newTeamName)
newTeamCfg := fmt.Sprintf(`
name: %q
team_settings:
secrets:
- secret: "new-secret"
agent_options:
controls:
policies:
queries:
software:
app_store_apps:
- app_store_id: "1"
`, newTeamName)
tmpDir := t.TempDir()
globalFile := filepath.Join(tmpDir, "default.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(globalCfg), 0o600))
newTeamFile := filepath.Join(tmpDir, "new-team.yml")
require.NoError(t, os.WriteFile(newTeamFile, []byte(newTeamCfg), 0o600))
// Only default.yml + the new team's file — the existing team's file is omitted.
buf, err := fleetctltest.RunAppNoChecks([]string{
"gitops", "-f", globalFile, "-f", newTeamFile,
})
require.NoError(t, err)
assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked)
assert.True(t, ds.SetTeamVPPAppsFuncInvoked)
assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, newTeamName))
}
func TestGitOpsVPP(t *testing.T) {
global := func(mdm string) string {
return fmt.Sprintf(`
controls:
queries:
policies:
agent_options:
software:
org_settings:
server_settings:
server_url: "https://foo.example.com"
org_info:
org_name: GitOps Test
secrets:
- secret: "global"
mdm:
%s
`, mdm)
}
team := func(name string) string {
return fmt.Sprintf(`
name: %s
team_settings:
secrets:
- secret: "%s-secret"
agent_options:
controls:
policies:
queries:
software:
`, name, name)
}
workstations := team("💻 Workstations")
iosTeam := team("📱🏢 Company-owned iPhones")
ipadTeam := team("🔳🏢 Company-owned iPads")
cases := []struct {
name string
cfgs []string
dryRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error)
realRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error)
}{
{
name: "new key all valid",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
- "💻 Workstations"
- "📱🏢 Company-owned iPhones"
- "🔳🏢 Company-owned iPads"`),
workstations,
iosTeam,
ipadTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Fleet Device Management Inc.",
Teams: []string{
"💻 Workstations",
"📱🏢 Company-owned iPhones",
"🔳🏢 Company-owned iPads",
},
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "new key multiple elements",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Acme Inc.
teams:
- "💻 Workstations"
- location: Fleet Device Management Inc.
teams:
- "📱🏢 Company-owned iPhones"
- "🔳🏢 Company-owned iPads"`),
workstations,
iosTeam,
ipadTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Acme Inc.",
Teams: []string{
"💻 Workstations",
},
},
{
Location: "Fleet Device Management Inc.",
Teams: []string{
"📱🏢 Company-owned iPhones",
"🔳🏢 Company-owned iPads",
},
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "using an undefined team errors",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
- "💻 Workstations"
- "📱🏢 Company-owned iPhones"
- "🔳🏢 Company-owned iPads"`),
workstations,
ipadTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs")
},
},
{
name: "no team is supported",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
- "💻 Workstations"
- "📱🏢 Company-owned iPhones"
- "No team"`),
workstations,
iosTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Fleet Device Management Inc.",
Teams: []string{
"💻 Workstations",
"📱🏢 Company-owned iPhones",
"No team",
},
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "all teams is supported",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
- "All teams"`),
workstations,
iosTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Fleet Device Management Inc.",
Teams: []string{
"All teams",
},
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "all fleets is supported",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
- "All fleets"`),
workstations,
iosTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
require.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
require.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Fleet Device Management Inc.",
Teams: []string{
"All fleets",
},
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "not provided teams defaults to no team",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:`),
workstations,
ipadTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
assert.NoError(t, err)
assert.ElementsMatch(
t,
appCfg.MDM.VolumePurchasingProgram.Value,
[]fleet.MDMAppleVolumePurchasingProgramInfo{
{
Location: "Fleet Device Management Inc.",
Teams: nil,
},
},
)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "non existent location fails",
cfgs: []string{
global(`
volume_purchasing_program:
- location: Does not exist
teams:`),
workstations,
ipadTeam,
},
dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
require.ErrorContains(t, err, "token with organization unit Does not exist doesn't exist")
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.NotContains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) {
require.ErrorContains(t, err, "token with organization unit Does not exist doesn't exist")
assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value)
assert.NotContains(t, out, "[!] gitops dry run succeeded")
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ds, savedAppConfigPtr, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t)
// 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.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{{Location: "Fleet Device Management Inc.", CountryCode: "us"}, {Location: "Acme Inc.", CountryCode: "us"}}, nil
}
ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil
}
ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) {
return 1, nil
}
ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) {
var res []*fleet.TeamSummary
for _, tm := range savedTeams {
res = append(res, &fleet.TeamSummary{Name: (*tm).Name, ID: (*tm).ID})
}
return res, nil
}
ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error {
return 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
}
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{}, nil
}
args := []string{"gitops"}
for _, cfg := range tt.cfgs {
if cfg != "" {
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString(cfg)
require.NoError(t, err)
args = append(args, "-f", tmpFile.Name())
}
}
// Dry run
out, err := fleetctltest.RunAppNoChecks(append(args, "--dry-run"))
tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
if t.Failed() {
t.FailNow()
}
// Real run
out, err = fleetctltest.RunAppNoChecks(args)
tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
// Second real run, now that all the teams are saved
out, err = fleetctltest.RunAppNoChecks(args)
tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
})
}
}
// TestGitOpsTeamVPPAppleConfiguration covers the iOS / iPadOS managed app
// configuration path through gitops apply: a configuration.path file is read
// from disk, validated, and reaches SetTeamVPPApps as raw XML bytes; invalid
// XML or unsupported Fleet variables are rejected; and an absent configuration
// block clears any previously stored configuration.
func TestGitOpsTeamVPPAppleConfiguration(t *testing.T) {
testing_utils.StartAndServeVPPServer(t)
xmlPath := "../../fleetctl/testdata/gitops/team_vpp_ios_config.xml"
wantXML, err := os.ReadFile(xmlPath)
require.NoError(t, err)
cases := []struct {
name string
yaml string
wantErr string
// expectConfig is the expected value of VPPAppTeam.Configuration the
// datastore receives. Empty means SetTeamVPPApps should be called with
// no configuration (clear).
expectConfig []byte
}{
{
name: "valid configuration is read, validated, and stored as raw XML",
yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_with_config.yml",
expectConfig: wantXML,
},
{
name: "invalid Fleet variable is rejected",
yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_with_invalid_config.yml",
wantErr: "unsupported variable $FLEET_VAR_NDES_SCEP_PROXY_URL",
},
{
name: "missing configuration clears any prior configuration",
yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_no_config.yml",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
renewDate := time.Now().Add(24 * time.Hour)
token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca")
require.NoError(t, err)
var capturedApps []fleet.VPPAppTeam
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, apps []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
capturedApps = append(capturedApps, apps...)
return false, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{
ID: 1,
OrgName: "Fleet",
Location: "Earth",
RenewDate: renewDate,
Token: string(token),
Teams: nil,
CountryCode: "us",
}, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, 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
}
ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error {
return nil
}
ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error {
return nil
}
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.yaml})
if c.wantErr != "" {
require.ErrorContains(t, err, c.wantErr)
return
}
require.NoError(t, err)
require.True(t, ds.SetTeamVPPAppsFuncInvoked)
require.Len(t, capturedApps, 1)
require.Equal(t, c.expectConfig, capturedApps[0].Configuration)
})
}
}
// TestGitOpsTeamInHouseAppleConfiguration covers the iOS / iPadOS in-house app
// (.ipa) managed configuration path through gitops apply: a configuration.path
// file is read from disk, validated, and reaches BatchSetInHouseAppsInstallers
// as raw XML bytes; invalid XML or unsupported Fleet variables are rejected;
// and an absent configuration block clears any previously stored configuration.
func TestGitOpsTeamInHouseAppleConfiguration(t *testing.T) {
testing_utils.StartSoftwareInstallerServer(t)
testing_utils.StartAndServeVPPServer(t)
xmlPath := "../../fleetctl/testdata/gitops/team_in_house_config.xml"
wantXML, err := os.ReadFile(xmlPath)
require.NoError(t, err)
cases := []struct {
name string
yaml string
wantErr string
expectConfig []byte
}{
{
name: "valid configuration is read, validated, and stored as raw XML",
yaml: "../../fleetctl/testdata/gitops/team_in_house_with_config.yml",
expectConfig: wantXML,
},
{
name: "invalid Fleet variable is rejected",
yaml: "../../fleetctl/testdata/gitops/team_in_house_with_invalid_config.yml",
wantErr: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
},
{
name: "missing configuration produces no configuration on the payload",
yaml: "../../fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
var capturedInstallers []*fleet.UploadSoftwareInstallerPayload
ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
capturedInstallers = append(capturedInstallers, installers...)
return nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
}
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
return nil, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, 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
}
ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error {
return nil
}
ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error {
return nil
}
_, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.yaml})
if c.wantErr != "" {
require.ErrorContains(t, err, c.wantErr)
return
}
require.NoError(t, err)
require.NotEmpty(t, capturedInstallers, "expected BatchSetInHouseAppsInstallers to be called")
// .ipa generates two installers (iOS + iPadOS). All of them should
// carry the same Configuration value.
for _, inst := range capturedInstallers {
require.Equal(t, c.expectConfig, inst.Configuration)
}
})
}
}
func TestGitOpsSoftwareDownloadProgress(t *testing.T) {
testing_utils.StartSoftwareInstallerServer(t)
dev_mode.SetOverride("FLEET_DEV_BATCH_RETRY_INTERVAL", "1s")
t.Cleanup(func() { dev_mode.ClearOverride("FLEET_DEV_BATCH_RETRY_INTERVAL") })
file := "../../fleetctl/testdata/gitops/team_software_installer_valid.yml"
// The batch needs these to get as far as downloading; the harness doesn't set them.
setupSoftwareMocks := func(t *testing.T) map[string]**fleet.Team {
ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t)
ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil
}
ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
return nil, nil
}
ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) {
return map[string]uint{}, nil
}
return savedTeams
}
t.Run("a package Fleet downloads reports its progress", func(t *testing.T) {
setupSoftwareMocks(t)
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file})
require.NoError(t, err)
require.Contains(t, out.String(), "[+] applying 2 software packages for fleet "+teamName+"\n")
require.Contains(t, out.String(), "[+] downloading software package - ruby.deb ...\n")
require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n")
require.Contains(t, out.String(), "[+] applied 2 software packages for fleet "+teamName+"\n")
})
t.Run("a dry run for an existing fleet reports the same progress", func(t *testing.T) {
// A dry run for a fleet that doesn't exist yet never starts a batch, so there is
// nothing to download and nothing to report.
savedTeams := setupSoftwareMocks(t)
team := &fleet.Team{ID: 1, Name: teamName}
savedTeams[teamName] = &team
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file})
require.NoError(t, err)
require.Contains(t, out.String(), "[+] downloading software package - ruby.deb ...\n")
require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n")
require.Contains(t, out.String(), "[+] would've applied 2 software packages for fleet "+teamName+"\n")
})
t.Run("a script package stays out of the progress, since nothing is downloaded for it", func(t *testing.T) {
setupSoftwareMocks(t)
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_script_package.yml"})
require.NoError(t, err)
// The counts prove the script package was in the batch, not just missing from it.
require.Contains(t, out.String(), "[+] applying 2 software packages for fleet "+teamName+"\n")
require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n")
require.NotContains(t, out.String(), "install_ruby.sh")
require.Contains(t, out.String(), "[+] applied 2 software packages for fleet "+teamName+"\n")
})
t.Run("a package Fleet can't download reports the failure", func(t *testing.T) {
setupSoftwareMocks(t)
out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_not_found.yml"})
require.Error(t, err)
require.Contains(t, out.String(), "Error: could not download software package notfound.deb\n")
require.NotContains(t, out.String(), "[+] downloaded software package - notfound.deb")
})
}