mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
**Related issue:** Ref #34797 Ref #42675 ## Problem When a software installer spec has no `hash_sha256`, Fleet re-downloads the package, re-extracts metadata, and re-upserts the DB on every GitOps run, even if the upstream file hasn't changed. For deployments with 50+ URL-only packages across multiple teams, this wastes bandwidth and processing time on every run. ## Solution By default, use etags to avoid unnecessary downloads: 1. First run: Fleet downloads the package normally and stores the server's ETag header 2. Subsequent runs: Fleet sends a conditional GET with `If-None-Match`. If the server returns 304 Not Modified, Fleet skips the download, metadata extraction, S3 upload, and DB upsert entirely Opt-out with `always_download:true`, meaning packages continue to be downloaded and re-processed on every run, same as today. No UI changes needed. ```yaml url: https://nvidia.gpcloudservice.com/global-protect/getmsi.esp?version=64&platform=windows always_download: true install_script: path: install.ps1 ``` ### Why conditional GET instead of HEAD Fleet team [analysis of 276 maintained apps](https://github.com/fleetdm/fleet/pull/42216#issuecomment-4105430061) showed 7 apps where HEAD requests fail (405, 403, timeout) but GET works for all. Conditional GET eliminates that failure class: if the server doesn't support conditional requests, it returns 200 with the full body, same as today. ### Why opt-in 5 of 276 apps (1.8%) have stale ETags (content changes but ETag stays the same), caused by CDN caching artifacts (CloudFront, Cloudflare, nginx inode-based ETags). The `cache` key lets users opt in per package for URLs where they've verified ETag behavior is correct. Validation rejects `always_download: true` when hash_sha256` is set ## Changes - New YAML field: `cache` (bool, package-level) - New migration: `http_etag` VARCHAR(512) column (explicit `utf8mb4_unicode_ci` collation) + composite index `(global_or_team_id, url(255))` on `software_installers` - New datastore method: `GetInstallerByTeamAndURL` - `downloadURLFn` accepts optional `If-None-Match` header, returns 304 as `(resp, nil, nil)` with `http.NoBody` - ETag validated per RFC 7232 (ASCII printable only, no control chars, max 512 bytes) at both write and read time - Cache skipped for `.ipa` packages (multi-platform extraInstallers) - TempFileReader and HTTP response leak prevention on download retry - Docs updated in `yaml-files.md` ## What doesn't change - Packages with `hash_sha256`: existing hash-based skip, untouched - FMA packages: FMA version cache, untouched - Packages with `always_download: true`: identical to current behavior - Fleet UI: no changes ## Test plan Automated testing: - [x] 16 unit tests for `validETag` - [x] 8 unit tests for conditional GET behavior (304, 200, 403, 500, weak ETag, S3 multipart, no ETag) - [x] MySQL integration test for `GetInstallerByTeamAndURL` - [x] All 23 existing `TestSoftwareInstallers` datastore tests pass - [x] All existing service tests pass Manual testing: - [x] E2E: 86 packages across 6 CDN patterns, second apply shows 51 conditional hits (304) - [x] @sgress454 used a local fileserver tool to test w/ a new instance and dummy packages <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ETag-based conditional downloads to skip unchanged remote installer files. * New always_download flag to force full re-downloads. * **Tests** * Added integration and unit tests covering conditional GETs, ETag validation, retries, edge cases, and payload behavior. * **Chores** * Persist HTTP ETag and related metadata; DB migration and index to speed installer lookups. * Added installer lookup by team+URL to support conditional download flow. * **Bug Fix** * Rejects using always_download together with an explicit SHA256 in uploads. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Scott Gress <scott@fleetdm.com> Co-authored-by: Scott Gress <scott@pigandcow.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
1197 lines
45 KiB
Go
1197 lines
45 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/testing_utils"
|
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
|
"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 should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .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,
|
|
}, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
|
return []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 = fleetctl.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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
|
return []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 := fleetctl.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml"})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
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,
|
|
}, 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.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
|
|
}
|
|
|
|
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 = fleetctl.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 should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .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,
|
|
}, 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.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
|
|
}
|
|
|
|
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 = fleetctl.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,
|
|
}, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) {
|
|
return []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 = fleetctl.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,
|
|
}
|
|
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
|
|
}
|
|
|
|
buf, err := fleetctl.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,
|
|
}
|
|
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(`
|
|
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: "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) {
|
|
assert.ErrorContains(t, err, "token with location 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) {
|
|
assert.ErrorContains(t, err, "token with location 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."}, {Location: "Acme Inc."}}, 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 := fleetctl.RunAppNoChecks(append(args, "--dry-run"))
|
|
tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
|
|
if t.Failed() {
|
|
t.FailNow()
|
|
}
|
|
|
|
// Real run
|
|
out, err = fleetctl.RunAppNoChecks(args)
|
|
tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
|
|
|
|
// Second real run, now that all the teams are saved
|
|
out, err = fleetctl.RunAppNoChecks(args)
|
|
tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err)
|
|
})
|
|
}
|
|
}
|