diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 5f24b4ad35..18dee1b3f1 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -88,9 +88,9 @@ type generateGitopsClient interface { GetAppleMDMEnrollmentProfile(teamID uint) (*fleet.MDMAppleSetupAssistant, error) GetCertificateAuthoritiesSpec(includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) GetCertificateTemplates(teamID string) ([]*fleet.CertificateTemplateResponseSummary, error) + ListFleetMaintainedApps(teamID uint) ([]fleet.MaintainedApp, error) GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error) GetVPPTokens() ([]*fleet.VPPTokenDB, error) - ListFleetMaintainedApps(teamID uint) ([]fleet.MaintainedApp, error) } // Given a struct type and a field name, return the JSON field name. @@ -1656,6 +1656,147 @@ func (cmd *GenerateGitopsCommand) generateQueries(teamId *uint) ([]map[string]in return result, nil } +// fmaSlugResolver lazily fetches Fleet-maintained apps for a team via the API +// and builds a map of FMA ID to slug for slug resolution. +type fmaSlugResolver struct { + client generateGitopsClient + teamID uint + appsList []fleet.MaintainedApp + byID map[uint]string +} + +func (r *fmaSlugResolver) resolve(fmaID uint) (string, error) { + if r.byID == nil { + if r.appsList == nil { + var err error + r.appsList, err = r.client.ListFleetMaintainedApps(r.teamID) + if err != nil { + return "", err + } + } + r.byID = make(map[uint]string, len(r.appsList)) + for _, a := range r.appsList { + r.byID[a.ID] = a.Slug + } + } + return r.byID[fmaID], nil +} + +// isDuplicateInHouseApp returns true if this in-house app (.ipa) has already +// been seen, tracking by filename. In-house apps generate two software titles +// (one for iOS, one for iPadOS) so we deduplicate them. +func isDuplicateInHouseApp(sw fleet.SoftwareTitleListResult, seen map[string]struct{}) bool { + if sw.SoftwarePackage == nil { + return false + } + if filepath.Ext(sw.SoftwarePackage.Name) != ".ipa" { + return false + } + if _, ok := seen[sw.SoftwarePackage.Name]; ok { + return true + } + seen[sw.SoftwarePackage.Name] = struct{}{} + return false +} + +// generateSoftwareForValidation produces a minimal software spec from +// server-side data for GitOps validation (policy install_software and patch +// policy references). It only calls ListSoftwareTitles (not GetSoftwareTitleByID +// per title), skipping scripts, icons, setup experience, labels, etc. +// It also returns SoftwarePackageResponse/VPPAppResponse slices with title IDs +// for policy title ID resolution in doGitOpsPolicies. +func generateSoftwareForValidation(client generateGitopsClient, appConfig *fleet.EnrichedAppConfig, teamID uint) ( + softwareSpec map[string]any, + installers []fleet.SoftwarePackageResponse, + vppApps []fleet.VPPAppResponse, + err error, +) { + const perPage = 1000 + var titles []fleet.SoftwareTitleListResult + for page := 0; ; page++ { + query := fmt.Sprintf("available_for_install=1&fleet_id=%d&per_page=%d&page=%d", teamID, perPage, page) + pageTitles, err := client.ListSoftwareTitles(query) + if err != nil { + return nil, nil, nil, err + } + titles = append(titles, pageTitles...) + if len(pageTitles) < perPage { + break + } + } + if len(titles) == 0 { + return nil, nil, nil, nil + } + + result := make(map[string]any) + packages := make([]map[string]any, 0) + appStoreApps := make([]map[string]any, 0) + fmas := make([]map[string]any, 0) + dedupeInHouseApps := make(map[string]struct{}) + slugResolver := &fmaSlugResolver{client: client, teamID: teamID} + + for _, sw := range titles { + if isDuplicateInHouseApp(sw, dedupeInHouseApps) { + continue + } + + spec := make(map[string]any) + titleID := sw.ID + + switch { + case sw.SoftwarePackage != nil: + installer := fleet.SoftwarePackageResponse{TitleID: &titleID} + if sw.SoftwarePackage.PackageURL != nil { + installer.URL = *sw.SoftwarePackage.PackageURL + } + if sw.HashSHA256 != nil { + installer.HashSHA256 = *sw.HashSHA256 + } + + if sw.SoftwarePackage.FleetMaintainedAppID != nil { + slug, err := slugResolver.resolve(*sw.SoftwarePackage.FleetMaintainedAppID) + if err != nil { + return nil, nil, nil, err + } + spec["slug"] = slug + installer.Slug = slug + fmas = append(fmas, spec) + } else { + // hash_sha256 and url are only valid for packages, not FMAs. + if sw.HashSHA256 != nil { + spec["hash_sha256"] = *sw.HashSHA256 + } + if sw.SoftwarePackage.PackageURL != nil { + spec["url"] = *sw.SoftwarePackage.PackageURL + } + packages = append(packages, spec) + } + installers = append(installers, installer) + + case sw.AppStoreApp != nil: + spec["app_store_id"] = sw.AppStoreApp.AppStoreID + appStoreApps = append(appStoreApps, spec) + vppApps = append(vppApps, fleet.VPPAppResponse{ + TitleID: &titleID, + AppStoreID: sw.AppStoreApp.AppStoreID, + Platform: fleet.InstallableDevicePlatform(sw.AppStoreApp.Platform), + }) + } + } + + if len(packages) > 0 { + result["packages"] = packages + } + if len(appStoreApps) > 0 { + result["app_store_apps"] = appStoreApps + } + if len(fmas) > 0 { + result["fleet_maintained_apps"] = fmas + } + + return result, installers, vppApps, nil +} + func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, teamFilename string, downloadIcons bool) (map[string]interface{}, error) { if !cmd.AppConfig.License.IsPremium() { return nil, nil // software is premium-only @@ -1699,25 +1840,16 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, packages := make([]map[string]any, 0) appStoreApps := make([]map[string]any, 0) fmas := make([]map[string]any, 0) - var appsList []fleet.MaintainedApp - - // in-house apps generate two software titles for the same gitops entry: one - // for iOS and one for iPadOS. Use this set to deduplicate them (by filename, - // which is unique for a given team and platform). - dedupeInHouseAppsByFilename := make(map[string]struct{}) - var byFMAID map[uint]string + dedupeInHouseApps := make(map[string]struct{}) + slugResolver := &fmaSlugResolver{client: cmd.Client, teamID: teamID} for _, sw := range software { + if isDuplicateInHouseApp(sw, dedupeInHouseApps) { + continue + } + softwareSpec := make(map[string]interface{}) switch { case sw.SoftwarePackage != nil: - if isInHouseApp := filepath.Ext(sw.SoftwarePackage.Name) == ".ipa"; isInHouseApp { - if _, ok := dedupeInHouseAppsByFilename[sw.SoftwarePackage.Name]; ok { - // ignore duplicate in-house app - continue - } - dedupeInHouseAppsByFilename[sw.SoftwarePackage.Name] = struct{}{} - } - pkgName := "" if sw.SoftwarePackage.Name != "" { pkgName = fmt.Sprintf(" (%s)", sw.SoftwarePackage.Name) @@ -1772,22 +1904,10 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, var fmaInstallScriptModified, fmaUninstallScriptModified bool if softwareTitle.SoftwarePackage.FleetMaintainedAppID != nil { - if byFMAID == nil { - if appsList == nil { - var err error - // currently, the list FMA endpoint has no default pagination - appsList, err = cmd.Client.ListFleetMaintainedApps(teamID) - if err != nil { - return nil, err - } - } - byFMAID = make(map[uint]string, len(appsList)) - for _, a := range appsList { - byFMAID[a.ID] = a.Slug - } + slug, err = slugResolver.resolve(*softwareTitle.SoftwarePackage.FleetMaintainedAppID) + if err != nil { + return nil, err } - - slug = byFMAID[*softwareTitle.SoftwarePackage.FleetMaintainedAppID] fma, err := maintained_apps.Hydrate(context.Background(), &fleet.MaintainedApp{ ID: *softwareTitle.SoftwarePackage.FleetMaintainedAppID, Slug: slug, diff --git a/cmd/fleetctl/fleetctl/gitops.go b/cmd/fleetctl/fleetctl/gitops.go index 548cd9df6b..2c1f135111 100644 --- a/cmd/fleetctl/fleetctl/gitops.go +++ b/cmd/fleetctl/fleetctl/gitops.go @@ -1,6 +1,7 @@ package fleetctl import ( + "encoding/json" "errors" "fmt" "path/filepath" @@ -143,18 +144,6 @@ func gitopsCommand() *cli.Command { return errors.New("no license struct found in app config") } - // We need the controls from no-team.yml to apply them when applying the global app config. - noTeamControls, noTeamPresent, noTeamFilename, err := extractControlsForNoTeam(flFilenames, appConfig, gitOpsOpts) - if err != nil { - return fmt.Errorf("extracting controls from %s: %w", noTeamFilename, err) - } - // Log a deprecation warning if the user is still using no-team.yml - if noTeamPresent && noTeamFilename == "no-team.yml" { - if logging.TopicEnabled(logging.DeprecatedFieldTopic) { - logf("[!] no-team.yml is deprecated; please rename the file to 'unassigned.yml' and update the team name to 'Unassigned'.\n") - } - } - var originalABMConfig []any var originalVPPConfig []any var teamNames []string @@ -208,6 +197,73 @@ func gitopsCommand() *cli.Command { } } + // Check if a no-team/unassigned file is present (by filename, before parsing). + prefetchNoTeamSoftware := false + for _, flFilename := range flFilenames.Value() { + fn := filepath.Base(flFilename) + if fn == "no-team.yml" || fn == "unassigned.yml" { + prefetchNoTeamSoftware = true + break + } + } + + // When software is excepted from GitOps, pre-fetch server-side software + // for all existing teams (including "No team") so the parser can validate + // policy references, and DoGitOps can resolve policy title IDs. This must + // happen before extractControlsForNoTeam, which parses the no-team file + // and would otherwise fail validating policy software references. + if appConfig.GitOpsConfig.Exceptions.Software { + syntheticSoftwareByTeam := make(map[string]json.RawMessage) + // Pre-fetch for "No team" (unassigned hosts, teamID=0) if present. + if prefetchNoTeamSoftware { + softwareMap, installers, vppApps, err := generateSoftwareForValidation(fleetClient, appConfig, 0) + if err != nil { + return fmt.Errorf("getting software for unassigned hosts: %w", err) + } + if softwareMap != nil { + raw, err := json.Marshal(softwareMap) + if err != nil { + return fmt.Errorf("marshaling software for unassigned hosts: %w", err) + } + syntheticSoftwareByTeam[fleet.TeamNameNoTeam] = raw + teamsSoftwareInstallers[fleet.TeamNameNoTeam] = installers + teamsVPPApps[fleet.TeamNameNoTeam] = vppApps + } + } + for teamName, teamID := range teamIDLookup { + if teamID == nil || *teamID == 0 { + continue // skip global and no-team/unassigned (handled above). + } + softwareMap, installers, vppApps, err := generateSoftwareForValidation(fleetClient, appConfig, *teamID) + if err != nil { + return fmt.Errorf("getting software for team %q: %w", teamName, err) + } + if softwareMap == nil { + continue + } + raw, err := json.Marshal(softwareMap) + if err != nil { + return fmt.Errorf("marshaling software for team %q: %w", teamName, err) + } + syntheticSoftwareByTeam[teamName] = raw + teamsSoftwareInstallers[teamName] = installers + teamsVPPApps[teamName] = vppApps + } + gitOpsOpts.SyntheticSoftwareByTeam = syntheticSoftwareByTeam + } + + // We need the controls from no-team.yml to apply them when applying the global app config. + noTeamControls, noTeamPresent, noTeamFilename, err := extractControlsForNoTeam(flFilenames, appConfig, gitOpsOpts) + if err != nil { + return fmt.Errorf("extracting controls from %s: %w", noTeamFilename, err) + } + // Log a deprecation warning if the user is still using no-team.yml + if noTeamPresent && noTeamFilename == "no-team.yml" { + if logging.TopicEnabled(logging.DeprecatedFieldTopic) { + logf("[!] no-team.yml is deprecated; please rename the file to 'unassigned.yml' and update the team name to 'Unassigned'.\n") + } + } + // Used for keeping track of all label changes in this run. labelChanges := make(map[string][]spec.LabelChange) // team name -> label changes @@ -274,11 +330,14 @@ func gitopsCommand() *cli.Command { } } } + // When labels are excepted and the key is omitted, preserve + // existing labels (no-op). Otherwise delete/update as normal. labelChanges[teamName] = computeLabelChanges( flFilename, teamName, existingLabels, config.Labels, + appConfig.GitOpsConfig.Exceptions.Labels, ) } @@ -340,7 +399,7 @@ func gitopsCommand() *cli.Command { validLabelNames := make(map[string]struct{}) if globalLabelChanges, ok := labelChanges[spec.LabelAPIGlobalTeamName]; ok { for _, label := range globalLabelChanges { - if label.Op == "+" || label.Op == "=" { + if label.Op == "+" || label.Op == "=" || label.Op == "~" { validLabelNames[label.Name] = struct{}{} } } @@ -355,7 +414,7 @@ func gitopsCommand() *cli.Command { } if config.CoercedTeamName() != spec.LabelAPIGlobalTeamName { for _, label := range labelChanges[config.CoercedTeamName()] { - if label.Op == "+" || label.Op == "=" { + if label.Op == "+" || label.Op == "=" || label.Op == "~" { validLabelNames[label.Name] = struct{}{} } } @@ -694,6 +753,7 @@ func computeLabelChanges( teamName string, existingLabels []*fleet.LabelSpec, specifiedLabels []*fleet.LabelSpec, + labelsExcepted bool, ) []spec.LabelChange { var regularLabels []*fleet.LabelSpec var labelOperations []spec.LabelChange @@ -704,12 +764,12 @@ func computeLabelChanges( } } - // Handle the cases where the 'labels:' section is either nil (an empty 'labels:' section was specified, - // meaning remove-all) or an empty list (the 'labels:' section was not specified, so we do a no-op). + // If no labels are specified: either no-op if labels are excepted from GitOps, + // or else delete them all. if len(specifiedLabels) == 0 { - op := "=" - if specifiedLabels == nil { - op = "-" + op := "-" + if labelsExcepted { + op = "~" // preserved (excepted from GitOps, no action needed) } for _, l := range regularLabels { change := spec.LabelChange{Name: l.Name, Op: op, TeamName: teamName, FileName: filename} diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index ef9aaf582c..0d4fc735fc 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -379,9 +380,9 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) { GitopsModeEnabled: true, RepositoryURL: "https://didsomeonesaygitops.biz", Exceptions: fleet.GitOpsExceptions{ - Software: false, - Secrets: true, - Labels: true, + Labels: false, + Software: true, + Secrets: false, }, }, }, nil @@ -584,9 +585,9 @@ software: // GitOps should not overwrite GitOps UI Mode. assert.Equal(t, savedAppConfig.GitOpsConfig.GitopsModeEnabled, true) assert.Equal(t, savedAppConfig.GitOpsConfig.RepositoryURL, "https://didsomeonesaygitops.biz") - assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Labels, true) - assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Secrets, true) - assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Software, false) + assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Labels, false) + assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Software, true) + assert.Equal(t, savedAppConfig.GitOpsConfig.Exceptions.Secrets, false) // Check MDM settings require.True(t, savedAppConfig.MDM.EnableDiskEncryption.Value) @@ -689,6 +690,844 @@ software: // assert.Equal(t, "hydrant2_secret", h2.ClientSecret) } +func TestGitOpsExceptionEnforcement(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := testing_utils.RunServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + KeyValueStore: testing_utils.NewMemKeyValueStore(), + }, + ) + + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { + return nil, 0, 0, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return nil, nil + } + setupDefaultTeamConfigMocks(ds) + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { return nil } + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return nil, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { + return map[string]uint{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return nil, nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return nil, nil } + ds.BatchApplyCertificateAuthoritiesFunc = func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error { + return nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { + return false, nil + } + ds.ListSoftwareAutoUpdateSchedulesFunc = func(ctx context.Context, teamID uint, source string, optionalFilter ...fleet.SoftwareAutoUpdateScheduleFilter) ([]fleet.SoftwareAutoUpdateSchedule, error) { + return nil, nil + } + ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) { + return nil, nil, nil + } + ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{}, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + return nil, nil + } + + // Test: excepted keys present in YAML → error + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + GitOpsConfig: fleet.GitOpsConfig{ + GitopsModeEnabled: true, + RepositoryURL: "https://example.com/repo", + Exceptions: fleet.GitOpsExceptions{Labels: true, Secrets: true, Software: true}, + }, + }, nil + } + + // Labels excepted + present → error + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString(` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + org_name: Test +labels: + - name: test-label + query: SELECT 1 +controls: +policies: +agent_options: +`) + require.NoError(t, err) + _, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"labels" is excepted from GitOps management`) + + // Secrets excepted + present → error + tmpFile2, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile2.WriteString(` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + org_name: Test + secrets: + - secret: mysecret +controls: +policies: +agent_options: +`) + require.NoError(t, err) + _, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile2.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"secrets" is excepted from GitOps management`) + + // Secrets excepted + present → error + tmpFile3, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile3.WriteString(` +name: test +controls: +policies: +agent_options: +software: +`) + require.NoError(t, err) + _, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile3.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"software" is excepted from GitOps management`) + + // Test: exceptions enforced even when GitOps mode is OFF (decoupled from UI mode) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + GitOpsConfig: fleet.GitOpsConfig{ + GitopsModeEnabled: false, + Exceptions: fleet.GitOpsExceptions{Labels: true}, + }, + }, nil + } + _, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"labels" is excepted from GitOps management`) +} + +// TestGitOpsExceptionsPreserveOmittedKeys verifies that when exceptions are ON, +// omitting the excepted keys from YAML preserves existing data. +func TestGitOpsExceptionsPreserveOmittedKeys(t *testing.T) { + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := testing_utils.RunServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + KeyValueStore: testing_utils.NewMemKeyValueStore(), + }, + ) + + // Tracking variables + var appliedSecrets []*fleet.EnrollSecret + var deletedLabels []string + + // --- Shared mocks --- + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + GitOpsConfig: fleet.GitOpsConfig{ + Exceptions: fleet.GitOpsExceptions{Labels: true, Secrets: true, Software: true}, + }, + }, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { return nil } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { + return nil, 0, 0, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return nil, nil + } + setupDefaultTeamConfigMocks(ds) + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return []*fleet.LabelSpec{ + {Name: "existing-label", LabelType: fleet.LabelTypeRegular, LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1"}, + }, nil + } + ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) error { + return errors.New("unexpected ApplyLabelSpecsWithAuthorFunc call - should not apply labels when excepted") + } + ds.SetAsideLabelsFunc = func(ctx context.Context, teamID *uint, names []string, user fleet.User) error { + return nil + } + ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) { + return &fleet.Label{ID: 1, Name: name}, nil + } + ds.DeleteLabelFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) error { + deletedLabels = append(deletedLabels, name) + return nil + } + ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) { + return map[string]*fleet.Label{}, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + return errors.New("unexpected ApplyEnrollSecretsFunc call - should not apply enroll secrets when excepted") + } + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { + return map[string]uint{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return nil, nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return nil, nil } + ds.BatchApplyCertificateAuthoritiesFunc = func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error { + return nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { + return false, nil + } + ds.ListSoftwareAutoUpdateSchedulesFunc = func(ctx context.Context, teamID uint, source string, optionalFilter ...fleet.SoftwareAutoUpdateScheduleFilter) ([]fleet.SoftwareAutoUpdateSchedule, error) { + return nil, nil + } + ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) { + return nil, nil, nil + } + ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{}, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } + var savedTeam *fleet.Team + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if savedTeam != nil && savedTeam.Name == name { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } + ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error { + return nil + } + ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { + newTeam.ID = 1 + savedTeam = newTeam + return newTeam, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) { + return true, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } + ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { + return nil, nil + } + ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { + return nil, nil + } + + // Global config that omits labels, secrets, and software + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + org_name: Test +controls: +policies: +agent_options: +`) + require.NoError(t, err) + + _, err = RunAppNoChecks([]string{"gitops", "-f", globalFile.Name()}) + require.NoError(t, err) + + // Labels should NOT have been deleted (excepted) + assert.Empty(t, deletedLabels, "labels should be preserved when excepted and key is omitted") + // Secrets should NOT have been applied (excepted) + assert.Nil(t, appliedSecrets, "secrets should be preserved when excepted and key is omitted") + // Software is global — not applicable (software exceptions only apply to team configs) + // so we don't assert on appliedSoftware here. + + // Team secrets and software preservation are verified in the integration test + // TestOmittedTopLevelKeysFleet, since both are applied as part of the team spec payload + // (not via separate mock-trackable calls). +} + +// TestGitOpsSoftwareExceptionPolicyValidation verifies that when software is excepted +// and the software: key is omitted from YAML, policies with install_software references +// (hash_sha256 for packages, app_store_id for VPP apps) still validate successfully +// against server-side software data. +func TestGitOpsSoftwareExceptionPolicyValidation(t *testing.T) { + policySpecsByTeam := make(map[string][]*fleet.PolicySpec) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := testing_utils.RunServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + KeyValueStore: testing_utils.NewMemKeyValueStore(), + }, + ) + + // --- Shared mocks --- + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + GitOpsConfig: fleet.GitOpsConfig{ + Exceptions: fleet.GitOpsExceptions{Labels: true, Secrets: true, Software: true}, + }, + }, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { return nil } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { + return nil, 0, 0, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{{ID: 1, Name: "Test Fleet"}}, nil + } + setupDefaultTeamConfigMocks(ds) + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return nil, nil + } + ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) error { + return nil + } + ds.SetAsideLabelsFunc = func(ctx context.Context, teamID *uint, names []string, user fleet.User) error { + return nil + } + ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) { + return &fleet.Label{ID: 1, Name: name}, nil + } + ds.DeleteLabelFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) error { + return nil + } + ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) { + return map[string]*fleet.Label{}, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { + return map[string]uint{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return nil, nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return nil, nil } + ds.BatchApplyCertificateAuthoritiesFunc = func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error { + return nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { + return false, nil + } + ds.ListSoftwareAutoUpdateSchedulesFunc = func(ctx context.Context, teamID uint, source string, optionalFilter ...fleet.SoftwareAutoUpdateScheduleFilter) ([]fleet.SoftwareAutoUpdateSchedule, error) { + return nil, nil + } + ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) { + return nil, nil, nil + } + ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{}, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == "Test Fleet" { + return &fleet.Team{ID: 1, Name: "Test Fleet"}, nil + } + return nil, ¬FoundError{} + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } + ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error { + return nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + return team, nil + } + ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) { + return true, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { + return nil, nil + } + ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { + return nil, nil + } + + ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + policySpecsByTeam[specs[0].Team] = specs + return nil + } + + // Server-side software for "Test Fleet" (teamID=1) and "No team" (teamID=0). + // This is what the pre-fetch will retrieve and inject as synthetic data into the parser. + // For team software, page 0 returns a full page of bogus results to exercise pagination, + // and page 1 returns the actual software that policies reference. + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + if opt.TeamID != nil && *opt.TeamID == 0 { + // No-team software — single page + return []fleet.SoftwareTitleListResult{ + { + ID: 30, + Name: "No-Team Package", + HashSHA256: ptr.String("eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111"), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "noteam-pkg.deb", + Platform: "linux", + Version: "2.0", + PackageURL: ptr.String("https://example.com/noteam-pkg.deb"), + }, + }, + }, 1, nil, nil + } + // Team software — paginated + if opt.ListOptions.Page == 0 { + // Page 0: full page of bogus packages that don't match any policy references. + bogus := make([]fleet.SoftwareTitleListResult, opt.ListOptions.PerPage) + for i := range bogus { + bogus[i] = fleet.SoftwareTitleListResult{ + ID: uint(1000 + i), + Name: fmt.Sprintf("Bogus Package %d", i), + HashSHA256: ptr.String(fmt.Sprintf("%064x", i)), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: fmt.Sprintf("bogus-%d.deb", i), + Platform: "linux", + Version: "0.0.1", + PackageURL: ptr.String(fmt.Sprintf("https://example.com/bogus-%d.deb", i)), + }, + } + } + return bogus, int(opt.ListOptions.PerPage), nil, nil //nolint:gosec // dismiss G115 + } + // Page 1: the real software that policies reference. + return []fleet.SoftwareTitleListResult{ + { + ID: 10, + Name: "Custom Package", + HashSHA256: ptr.String("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd"), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "custom-pkg.deb", + Platform: "linux", + Version: "1.0", + PackageURL: ptr.String("https://example.com/custom-pkg.deb"), + }, + }, + { + ID: 20, + Name: "VPP App", + AppStoreApp: &fleet.SoftwarePackageOrApp{ + AppStoreID: "5128675309", + Platform: string(fleet.MacOSPlatform), + }, + }, + }, 2, nil, nil + } + + // Config files that omit software: but have policies referencing server-side software + tmpDir := t.TempDir() + globalFile := filepath.Join(tmpDir, "default.yml") + require.NoError(t, os.WriteFile(globalFile, []byte(` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + org_name: Test +controls: +policies: +agent_options: +reports: +`), 0o644)) + + teamFile := filepath.Join(tmpDir, "test-team.yml") + require.NoError(t, os.WriteFile(teamFile, []byte(` +name: Test Fleet +policies: + - name: Package Policy + query: SELECT 1 + install_software: + hash_sha256: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd + - name: VPP Policy + query: SELECT 1 + install_software: + app_store_id: "5128675309" +agent_options: +reports: +`), 0o644)) + + unassignedFile := filepath.Join(tmpDir, "unassigned.yml") + require.NoError(t, os.WriteFile(unassignedFile, []byte(` +name: Unassigned +policies: + - name: No-Team Package Policy + query: SELECT 1 + install_software: + hash_sha256: eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111eeee1111 +`), 0o644)) + + // Run gitops — should succeed because the synthetic software injection + // provides the server-side data for policy validation on both team and no-team. + _, err := RunAppNoChecks([]string{"gitops", "-f", globalFile, "-f", teamFile, "-f", unassignedFile}) + require.NoError(t, err, "gitops should succeed when policies reference server-side software and software is excepted") + // Check that policies for "Test Fleet" contained the expected software title IDs. + testFleetPolicySpecs := policySpecsByTeam["Test Fleet"] + require.Len(t, testFleetPolicySpecs, 2, "expected 2 policies for Test Fleet") + for _, spec := range testFleetPolicySpecs { + switch spec.Name { + case "Package Policy": + assert.Equal(t, uint(10), *spec.SoftwareTitleID, "expected server-side software ID to be injected into Package Policy spec") + case "VPP Policy": + assert.Equal(t, uint(20), *spec.SoftwareTitleID, "expected server-side software ID to be injected into VPP Policy spec") + default: + t.Errorf("unexpected policy name: %s", spec.Name) + } + } + // Check that no-team policy also had the expected software title ID. + noTeamPolicySpecs := policySpecsByTeam["No team"] + require.Len(t, noTeamPolicySpecs, 1, "expected 1 no-team policy") + assert.Equal(t, uint(30), *noTeamPolicySpecs[0].SoftwareTitleID, "expected server-side software ID to be injected into no-team policy spec") +} + +// TestGitOpsNoExceptionsClearOmittedKeys verifies that when exceptions are OFF, +// omitting keys from YAML clears existing data. +func TestGitOpsNoExceptionsClearOmittedKeys(t *testing.T) { + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := testing_utils.RunServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + KeyValueStore: testing_utils.NewMemKeyValueStore(), + }, + ) + + // Tracking variables + appliedSecrets := []*fleet.EnrollSecret{ + {Secret: "existing-secret"}, + {Secret: "another-secret"}, + } + var deletedLabels []string + + savedTeam := &fleet.Team{ + Name: "TestTeam", + ID: 1, + Secrets: []*fleet.EnrollSecret{{Secret: "existing-secret"}}, + Config: fleet.TeamConfig{ + Software: &fleet.SoftwareSpec{ + Packages: optjson.SetSlice([]fleet.SoftwarePackageSpec{{URL: "http://example.com"}}), + FleetMaintainedApps: optjson.SetSlice([]fleet.MaintainedAppSpec{{Slug: "someapp"}}), + AppStoreApps: optjson.SetSlice([]fleet.TeamSpecAppStoreApp{{AppStoreID: "someapp"}}), + }, + }, + } + + // --- Shared mocks (all exceptions OFF) --- + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + GitOpsConfig: fleet.GitOpsConfig{ + Exceptions: fleet.GitOpsExceptions{}, // all false + }, + }, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { return nil } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { + return nil, 0, 0, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{savedTeam}, nil + } + setupDefaultTeamConfigMocks(ds) + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + if filter.TeamID != nil && *filter.TeamID == 1 { + return []*fleet.LabelSpec{ + {Name: "existing-team-label", LabelType: fleet.LabelTypeRegular, LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1", TeamID: ptr.Uint(1)}, + }, nil + } + return []*fleet.LabelSpec{ + {Name: "existing-label", LabelType: fleet.LabelTypeRegular, LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1"}, + }, nil + } + ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) error { + return errors.New("unexpected ApplyLabelSpecsWithAuthorFunc call - should not apply labels when all are deleted") + } + ds.SetAsideLabelsFunc = func(ctx context.Context, teamID *uint, names []string, user fleet.User) error { + return nil + } + ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) { + return &fleet.Label{ID: 1, Name: name}, nil + } + ds.DeleteLabelFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) error { + deletedLabels = append(deletedLabels, name) + return nil + } + ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) { + return map[string]*fleet.Label{}, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + appliedSecrets = secrets + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { + return map[string]uint{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return nil, nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return nil, nil } + ds.BatchApplyCertificateAuthoritiesFunc = func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error { + return nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { + return false, nil + } + ds.ListSoftwareAutoUpdateSchedulesFunc = func(ctx context.Context, teamID uint, source string, optionalFilter ...fleet.SoftwareAutoUpdateScheduleFilter) ([]fleet.SoftwareAutoUpdateSchedule, error) { + return nil, nil + } + ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) { + return nil, nil, nil + } + ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{}, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } + + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + return savedTeam, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if savedTeam != nil && savedTeam.Name == name { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } + ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error { + return nil + } + ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { + newTeam.ID = 1 + savedTeam = newTeam + return newTeam, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) { + return true, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } + ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { + return nil, nil + } + ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { + return nil, nil + } + + // Global config that omits labels and secrets + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + org_name: Test +controls: +policies: +agent_options: +`) + require.NoError(t, err) + + _, err = RunAppNoChecks([]string{"gitops", "-f", globalFile.Name()}) + require.NoError(t, err) + + // Labels SHOULD have been deleted (not excepted, key omitted) + assert.Equal(t, []string{"existing-label"}, deletedLabels, "labels should be cleared when not excepted and key is omitted") + // Secrets SHOULD have been applied as empty (not excepted, key omitted) + assert.NotNil(t, appliedSecrets, "secrets should be cleared when not excepted and key is omitted") + assert.Empty(t, appliedSecrets, "secrets should be applied as empty list") + + // Clear out deletedLabels + deletedLabels = nil + // Clear out appliedSecrets + appliedSecrets = nil + + // Now test team config: omit secrets and software + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = teamFile.WriteString(` +name: TestTeam +controls: +policies: +agent_options: +`) + require.NoError(t, err) + _, err = RunAppNoChecks([]string{"gitops", "-f", teamFile.Name()}) + require.NoError(t, err) + // Check that secrets is empty on the saved team + assert.NotNil(t, savedTeam.Secrets, "team secrets should not be nil") + assert.Empty(t, savedTeam.Secrets, "team secrets should be cleared when not excepted and key is omitted") + // Labels SHOULD have been deleted (not excepted, key omitted) + assert.Equal(t, []string{"existing-team-label"}, deletedLabels, "labels should be cleared when not excepted and key is omitted") + // Software clearing requires enterprise setup, which is tested in the integration test TestOmittedTopLevelKeysFleet. +} + func TestGitOpsBasicTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} @@ -1240,20 +2079,6 @@ func TestGitOpsFullGlobal(t *testing.T) { assert.Len(t, appliedLabelSpecs, 0) assert.Len(t, deletedLabels, 0) - // Dry run w/out top-level labels key - logs = RunAppForTest(t, []string{"gitops", "-f", "./testdata/gitops/global_config_no_paths_no_labels.yml", "--dry-run"}) - fmt.Printf("%s", logs) - fmt.Printf("-----------\n") - assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") - assert.Len(t, enrolledSecrets, 0) - assert.Len(t, appliedPolicySpecs, 0) - assert.Len(t, appliedQueries, 0) - assert.Len(t, appliedScripts, 0) - assert.Len(t, appliedMacProfiles, 0) - assert.Len(t, appliedWinProfiles, 0) - assert.Len(t, appliedLabelSpecs, 0) - assert.Len(t, deletedLabels, 0) - // Real run w/ top-level labels key logs = RunAppForTest(t, []string{"gitops", "-f", "./testdata/gitops/global_config_no_paths.yml"}) fmt.Printf("%s", logs) @@ -1299,15 +2124,6 @@ func TestGitOpsFullGlobal(t *testing.T) { } require.NotNil(t, labelD, "label d should be in applied specs") assert.Nil(t, labelD.Hosts, "omitting hosts key should result in nil Hosts (preserve membership)") - - // Reset labels arrays - deletedLabels = make([]string, 0) - appliedLabelSpecs = make([]*fleet.LabelSpec, 0) - // Real run w/out top-level labels key - logs = RunAppForTest(t, []string{"gitops", "-f", "./testdata/gitops/global_config_no_paths_no_labels.yml"}) - fmt.Printf("%s", logs) - assert.Len(t, appliedLabelSpecs, 0) - assert.Len(t, deletedLabels, 0) } func TestGitOpsFullTeam(t *testing.T) { @@ -1821,7 +2637,12 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { return nil } - testing_utils.AddLabelMocks(ds) + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return nil, nil + } + ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) error { + return nil + } // Mock DefaultTeamConfig functions for No Team webhook settings setupDefaultTeamConfigMocks(ds) @@ -2228,7 +3049,12 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { return nil, 0, 0, nil, nil } - testing_utils.AddLabelMocks(ds) + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return nil, nil + } + ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) error { + return nil + } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { job.ID = 1 @@ -2830,6 +3656,12 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { require.Len(t, enrolledTeamSecrets, 2) t.Run("no-team.yml using relative paths", func(t *testing.T) { + // Override label mocks to return no existing labels, since these YAML files + // don't include `labels:` and multi-file deletion would cause dedup errors. + ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { + return nil, nil + } + globalFileBasic := createGlobalFileBasic(t, fleetServerURL, orgName) teamFileBasic := createTeamFileBasic(t, teamName) @@ -3338,6 +4170,10 @@ software: 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.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { if len(tt.tokens) > 0 { @@ -5258,10 +6094,11 @@ func TestComputeLabelChanges(t *testing.T) { teamName string existingLabels []*fleet.LabelSpec specifiedLabels []*fleet.LabelSpec + labelsExcepted bool expected []spec.LabelChange }{ { - name: "no specified labels removes all regular labels", + name: "labels omitted removes all regular labels when not excepted", filename: "config.yml", teamName: "team1", existingLabels: []*fleet.LabelSpec{ @@ -5269,20 +6106,36 @@ func TestComputeLabelChanges(t *testing.T) { {Name: "built-in", LabelType: fleet.LabelTypeBuiltIn}, }, specifiedLabels: nil, + labelsExcepted: false, expected: []spec.LabelChange{ {Name: "label1", Op: "-", TeamName: "team1", FileName: "config.yml"}, }, }, { - name: "empty list of specified labels is a no-op", + name: "labels empty removes all regular labels when not excepted", + filename: "config.yml", + teamName: "team1", + existingLabels: []*fleet.LabelSpec{ + {Name: "label1", LabelType: fleet.LabelTypeRegular}, + {Name: "built-in", LabelType: fleet.LabelTypeBuiltIn}, + }, + specifiedLabels: []*fleet.LabelSpec{}, + labelsExcepted: false, + expected: []spec.LabelChange{ + {Name: "label1", Op: "-", TeamName: "team1", FileName: "config.yml"}, + }, + }, + { + name: "labels omitted is a no-op when excepted", filename: "config.yml", teamName: "team1", existingLabels: []*fleet.LabelSpec{ {Name: "label1", LabelType: fleet.LabelTypeRegular}, }, - specifiedLabels: []*fleet.LabelSpec{}, + specifiedLabels: nil, + labelsExcepted: true, expected: []spec.LabelChange{ - {Name: "label1", Op: "=", TeamName: "team1", FileName: "config.yml"}, + {Name: "label1", Op: "~", TeamName: "team1", FileName: "config.yml"}, }, }, { @@ -5297,6 +6150,7 @@ func TestComputeLabelChanges(t *testing.T) { {Name: "to-keep"}, {Name: "to-add"}, }, + labelsExcepted: false, expected: []spec.LabelChange{ {Name: "to-remove", Op: "-", TeamName: "team1", FileName: "config.yml"}, {Name: "to-keep", Op: "=", TeamName: "team1", FileName: "config.yml"}, @@ -5307,7 +6161,7 @@ func TestComputeLabelChanges(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - changes := computeLabelChanges(tc.filename, tc.teamName, tc.existingLabels, tc.specifiedLabels) + changes := computeLabelChanges(tc.filename, tc.teamName, tc.existingLabels, tc.specifiedLabels, tc.labelsExcepted) require.ElementsMatch(t, tc.expected, changes) }) } diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml b/cmd/fleetctl/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml index e6231bf030..46e99609c1 100644 --- a/cmd/fleetctl/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml +++ b/cmd/fleetctl/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml @@ -97,4 +97,16 @@ org_settings: databases_path: "" secrets: - secret: ABC +labels: + - name: A + label_membership_type: manual + hosts: + - host2 + - host3 + - name: B + label_membership_type: dynamic + query: SELECT 1 from osquery_info + - name: C + label_membership_type: dynamic + query: SELECT 1 from osquery_info software: diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index cf89436f1c..7f29d1d45c 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -819,7 +819,13 @@ func AddLabelMocks(ds *mock.Store) { ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) (err error) { return nil } + ds.SetAsideLabelsFunc = func(ctx context.Context, teamID *uint, names []string, user fleet.User) error { + return nil + } + ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) { + return &fleet.Label{ID: 1, Name: name}, nil + } ds.DeleteLabelFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) error { deletedLabels = append(deletedLabels, name) return nil diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go index deb40eb803..57f53e59a9 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go @@ -155,10 +155,6 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabe user := s.createGitOpsUser(t) fleetctlConfig := s.createFleetctlConfig(t, user) - lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"}) - require.NoError(t, err) - require.NotZero(t, lbl.ID) - profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig") require.NoError(t, err) _, err = profileFile.WriteString(test.GenerateMDMAppleProfile("test", "test", uuid.NewString())) @@ -169,6 +165,9 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabe const ( globalTemplate = ` agent_options: +labels: + - name: Label1 + query: select 1 controls: macos_settings: custom_settings: @@ -280,13 +279,13 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetSoftwareInstallerLabelsD user := s.createGitOpsUser(t) fleetctlConfig := s.createFleetctlConfig(t, user) - lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"}) - require.NoError(t, err) - require.NotZero(t, lbl.ID) const ( globalTemplate = ` agent_options: +labels: + - name: Label1 + query: select 1 controls: org_settings: server_settings: diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 184a3f355d..6795059abe 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -132,6 +132,9 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() { appConf, err = s.DS.AppConfig(context.Background()) require.NoError(s.T(), err) appConf.ServerSettings.ServerURL = server.URL + // Disable gitops exceptions so that existing tests can freely use labels, secrets, etc. in their YAML. + // Tests that specifically test exception enforcement should re-enable them. + appConf.GitOpsConfig.Exceptions = fleet.GitOpsExceptions{} err = s.DS.SaveAppConfig(context.Background(), appConf) require.NoError(s.T(), err) } @@ -915,9 +918,6 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabe user := s.createGitOpsUser(t) fleetctlConfig := s.createFleetctlConfig(t, user) - lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"}) - require.NoError(t, err) - require.NotZero(t, lbl.ID) profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig") require.NoError(t, err) @@ -929,6 +929,9 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabe const ( globalTemplate = ` agent_options: +labels: + - name: Label1 + query: select 1 controls: macos_settings: custom_settings: @@ -1040,13 +1043,13 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetSoftwareInstallerLabels( user := s.createGitOpsUser(t) fleetctlConfig := s.createFleetctlConfig(t, user) - lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"}) - require.NoError(t, err) - require.NotZero(t, lbl.ID) const ( globalTemplate = ` agent_options: +labels: + - name: Label1 + query: select 1 controls: org_settings: server_settings: @@ -4167,16 +4170,15 @@ org_settings: require.NoError(t, err) require.Len(t, queries, 0) - // Verify secrets are unchanged. + // Verify secrets are cleared. globalSecrets, err = s.DS.GetEnrollSecrets(ctx, nil) require.NoError(t, err) - require.Len(t, globalSecrets, 1) - require.Equal(t, "boofar", globalSecrets[0].Secret) + require.Len(t, globalSecrets, 0) - // Verify labels are unchanged. + // Verify labels are cleared. labels, err = s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{}) require.NoError(t, err) - require.Len(t, labels, 1) + require.Len(t, labels, 0) } // TestOmittedTopLevelKeysFleet verifies that omitting top-level keys from a fleet @@ -4216,6 +4218,11 @@ reports: software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb +labels: + - name: Test Fleet Label + label_membership_type: dynamic + query: SELECT 1 + `, fleetName) fullFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml") @@ -4294,17 +4301,21 @@ name: %s require.NoError(t, err) require.Len(t, flQueries, 0) - // Verify secrets are unchanged. + // Verify secrets are cleared. flSecrets, err = s.DS.GetEnrollSecrets(ctx, &fl.ID) require.NoError(t, err) - require.Len(t, flSecrets, 1) - require.Equal(t, "foobar", flSecrets[0].Secret) + require.Len(t, flSecrets, 0) // Verify software was cleared. titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &fl.ID}, fleet.TeamFilter{User: test.UserAdmin}) require.NoError(t, err) require.Len(t, titles, 0) + + // Verify labels are cleared. + labels, err := s.DS.LabelsByName(ctx, []string{"Test Fleet Label"}, fleet.TeamFilter{TeamID: &fl.ID}) + require.NoError(t, err) + require.Len(t, labels, 0) } // TestFMALabelsIncludeAll tests that labels_include_all is correctly applied and @@ -4316,15 +4327,15 @@ func (s *enterpriseIntegrationGitopsTestSuite) TestFMALabelsIncludeAll() { user := s.createGitOpsUser(t) fleetctlConfig := s.createFleetctlConfig(t, user) - lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1" + t.Name(), Query: "SELECT 1"}) - require.NoError(t, err) - require.NotZero(t, lbl.ID) - slug := fmt.Sprintf("foo%s/darwin", t.Name()) - + lblName := "Label1" + t.Name() const ( globalTemplate = ` agent_options: +labels: + - name: %s + label_membership_type: dynamic + query: SELECT 1 controls: org_settings: server_settings: @@ -4362,11 +4373,11 @@ settings: withLabelsIncludeAll := fmt.Sprintf(` labels_include_all: - %s -`, lbl.Name) +`, lblName) globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) - _, err = globalFile.WriteString(globalTemplate) + _, err = fmt.Fprintf(globalFile, globalTemplate, lblName) require.NoError(t, err) err = globalFile.Close() require.NoError(t, err) @@ -4460,7 +4471,7 @@ settings: require.Empty(t, noTeamMeta.LabelsIncludeAny) require.Empty(t, noTeamMeta.LabelsExcludeAny) require.Len(t, noTeamMeta.LabelsIncludeAll, 1) - require.Equal(t, lbl.Name, noTeamMeta.LabelsIncludeAll[0].LabelName) + require.Equal(t, lblName, noTeamMeta.LabelsIncludeAll[0].LabelName) // Locate the FMA installer for the team and assert labels_include_all is set teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, @@ -4475,7 +4486,7 @@ settings: require.Empty(t, teamMeta.LabelsIncludeAny) require.Empty(t, teamMeta.LabelsExcludeAny) require.Len(t, teamMeta.LabelsIncludeAll, 1) - require.Equal(t, lbl.Name, teamMeta.LabelsIncludeAll[0].LabelName) + require.Equal(t, lblName, teamMeta.LabelsIncludeAll[0].LabelName) // Now re-apply without labels_include_all and confirm they are cleared err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, slug, noLabels), 0o644) diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go index 68388f191c..70b70620d2 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go @@ -86,6 +86,8 @@ func (s *integrationGitopsTestSuite) SetupSuite() { appConf, err = s.DS.AppConfig(context.Background()) require.NoError(s.T(), err) appConf.ServerSettings.ServerURL = server.URL + // Disable gitops exceptions so that existing tests can freely use labels, secrets, etc. in their YAML. + appConf.GitOpsConfig.Exceptions = fleet.GitOpsExceptions{} err = s.DS.SaveAppConfig(context.Background(), appConf) require.NoError(s.T(), err) } diff --git a/cmd/fleetctl/integrationtest/gitops/software_test.go b/cmd/fleetctl/integrationtest/gitops/software_test.go index 87f53ac568..45989cbc34 100644 --- a/cmd/fleetctl/integrationtest/gitops/software_test.go +++ b/cmd/fleetctl/integrationtest/gitops/software_test.go @@ -978,6 +978,10 @@ software: 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 diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 208f5a4fda..d16528e2a7 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -32,6 +32,11 @@ type LabelChangesSummary struct { LabelsMovements []LabelMovement } +// HasChanges returns true if there are any label additions, removals, updates, or movements. +func (s LabelChangesSummary) HasChanges() bool { + return len(s.LabelsToAdd) > 0 || len(s.LabelsToRemove) > 0 || len(s.LabelsToUpdate) > 0 || len(s.LabelsMovements) > 0 +} + func NewLabelChangesSummary(changes []LabelChange, moves []LabelMovement) LabelChangesSummary { r := LabelChangesSummary{ LabelsMovements: moves, @@ -335,6 +340,13 @@ type GitOps struct { Software GitOpsSoftware // FleetSecrets is a map of secret names to their values, extracted from FLEET_SECRET_ environment variables used in profiles and scripts. FleetSecrets map[string]string + + // LabelsPresent indicates that the `labels:` key was explicitly present in the YAML file. + LabelsPresent bool + // SoftwarePresent indicates that the `software:` key was explicitly present in the YAML file. + SoftwarePresent bool + // SecretsPresent indicates that the `secrets:` key was explicitly present in the YAML file. + SecretsPresent bool } type GitOpsSoftware struct { @@ -350,6 +362,13 @@ type GitOpsOptions struct { // AllowUnknownKeys causes unknown key errors to be logged as warnings // instead of returned as errors. AllowUnknownKeys bool + // SyntheticSoftwareByTeam maps team names to JSON-encoded software specs + // from the server. When the software: key is excepted from GitOps and + // omitted from the YAML, this data is injected so that parseSoftware can + // populate result.Software for policy validation (install_software and + // patch policy references). SoftwarePresent remains false so that + // downstream exception enforcement still works correctly. + SyntheticSoftwareByTeam map[string]json.RawMessage } // GitOpsFromFile parses a GitOps yaml file. @@ -457,9 +476,9 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig for _, topKey := range topKeys { // "name" is handled later with special logic based on the filename. - // "labels" is a special case where omitting is a no-op, rather than a directive to clear settings. - // settings keys were handled above. - if topKey == "name" || topKey == "labels" || topKey == "settings" || topKey == "org_settings" { + // "labels" and "software" are special cases where omitting may be a no-op (based on exception settings), + // rather than a directive to clear settings. settings keys were handled above. + if topKey == "name" || topKey == "labels" || topKey == "software" || topKey == "settings" || topKey == "org_settings" { continue } // "controls" can be set on _either_ global or "no team" file, and we can't say which it is if both @@ -478,12 +497,9 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig } } - // Get the labels. If `labels:` is specified but no labels are listed, this will - // set Labels as nil. If `labels:` isn't present at all, it will be set as an - // empty array. - if _, ok := top["labels"]; !ok { - result.Labels = make([]*fleet.LabelSpec, 0) - } else { + // Get the labels. LabelsPresent tracks whether the key was in the YAML. + if _, ok := top["labels"]; ok { + result.LabelsPresent = true multiError = parseLabels(top, result, baseDir, logFn, filePath, multiError) } // Get other top-level entities. @@ -492,7 +508,7 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig multiError = parseReports(top, result, baseDir, logFn, filePath, multiError) if appConfig != nil && appConfig.License.IsPremium() { - multiError = parseSoftware(top, result, baseDir, filePath, multiError) + multiError = parseSoftware(top, result, baseDir, filePath, options, multiError) } // Policies can reference software installers and scripts, thus we parse them after parseSoftware and parseControls. @@ -777,6 +793,7 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro // Any secrets present on the server will be retained. return multiError } + result.SecretsPresent = true // When secrets slice is empty, all secrets are removed. enrollSecrets := make([]*fleet.EnrollSecret, 0) if rawSecrets != nil { @@ -1721,14 +1738,29 @@ func parseReports(top map[string]json.RawMessage, result *GitOps, baseDir string var validSHA256Value = regexp.MustCompile(`\b[a-f0-9]{64}\b`) -func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, filePath string, multiError *multierror.Error) *multierror.Error { +func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, filePath string, options GitOpsOptions, multiError *multierror.Error) *multierror.Error { softwareRaw, ok := top["software"] + if ok { + result.SoftwarePresent = true + } if result.global() { if ok && string(softwareRaw) != "null" { return multierror.Append(multiError, errors.New("'software' cannot be set on global file")) } } else if !ok { - return multierror.Append(multiError, errors.New("'software' is required")) + // Software key is absent. If we have synthetic server-side data for this + // team (because software is excepted from GitOps), inject it so that + // policy install_software and patch policy references can be validated. + // SoftwarePresent remains false so downstream exception enforcement works. + if result.TeamName != nil && options.SyntheticSoftwareByTeam != nil { + if synthetic, hasSynthetic := options.SyntheticSoftwareByTeam[*result.TeamName]; hasSynthetic { + softwareRaw = synthetic + ok = true // allow processing below + } + } + if !ok { + return multiError + } } var software Software if len(softwareRaw) > 0 { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index f485021611..3b9c27c882 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -3403,3 +3403,100 @@ func TestParsePolicyInstallSoftware(t *testing.T) { assert.Equal(t, "bad_field", unknownErr.Field) }) } + +func TestGitOpsPresenceTracking(t *testing.T) { + t.Run("labels present", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +labels: + - name: test-label + query: SELECT 1 +`) + require.NoError(t, err) + assert.True(t, gitops.LabelsPresent) + }) + + t.Run("labels absent", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +`) + require.NoError(t, err) + assert.False(t, gitops.LabelsPresent) + assert.Nil(t, gitops.Labels, "absent labels should be nil") + }) + + t.Run("labels present but empty", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +labels: +`) + require.NoError(t, err) + assert.True(t, gitops.LabelsPresent) + assert.Nil(t, gitops.Labels, "empty labels section should result in nil") + }) + + t.Run("secrets present", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test + secrets: + - secret: mysecret +`) + require.NoError(t, err) + assert.True(t, gitops.SecretsPresent) + }) + + t.Run("secrets absent", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +`) + require.NoError(t, err) + assert.False(t, gitops.SecretsPresent) + }) + + t.Run("software present on team", func(t *testing.T) { + premiumConfig := &fleet.EnrichedAppConfig{} + premiumConfig.License = &fleet.LicenseInfo{Tier: fleet.TierPremium} + + path, basePath := createTempFile(t, "", ` +name: TestTeam +software: + packages: + - url: https://example.com/pkg.deb +`) + gitops, err := GitOpsFromFile(path, basePath, premiumConfig, nopLogf) + require.NoError(t, err) + assert.True(t, gitops.SoftwarePresent) + }) + + t.Run("software absent on team", func(t *testing.T) { + premiumConfig := &fleet.EnrichedAppConfig{} + premiumConfig.License = &fleet.LicenseInfo{Tier: fleet.TierPremium} + + path, basePath := createTempFile(t, "", ` +name: TestTeam +`) + gitops, err := GitOpsFromFile(path, basePath, premiumConfig, nopLogf) + require.NoError(t, err) + assert.False(t, gitops.SoftwarePresent) + }) +} diff --git a/server/service/client.go b/server/service/client.go index 32b33672c2..fb5f3306e2 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -922,8 +922,16 @@ func (c *Client) ApplyGroup( } // if setup_experience.software has some values, they must exist in the software - // packages or vpp apps. + // packages or vpp apps. When software is excepted from GitOps, the validation + // maps (tmSoftwarePackagesWithPaths/tmSoftwareAppsByAppID) are empty because + // the team spec doesn't include software. Additionally, setup_experience + // references packages by file path which server-side data doesn't have. + // The server will validate when the setup experience is applied. + softwareExcepted := viaGitOps && appconfig != nil && appconfig.GitOpsConfig.Exceptions.Software for tmName, setupSw := range tmMacSetupSoftware { + if softwareExcepted { + continue + } if err := validateTeamOrNoTeamMacOSSetupSoftware(tmName, setupSw, tmSoftwarePackagesWithPaths[tmName], tmSoftwareAppsByAppID[tmName]); err != nil { return nil, nil, nil, nil, err } @@ -1878,6 +1886,28 @@ func (c *Client) DoGitOps( group := spec.Group{} // as we parse the incoming gitops spec, we'll build out various group specs that will each be applied separately + // Check GitOps exception enforcement. When an entity type is excepted: + // - If the key is present in the YAML, fail with an error. + // - If the key is absent, it's a no-op (existing entities preserved). + // When an entity type is NOT excepted: + // - If the key is absent, all entities of that type are deleted. + var exceptions fleet.GitOpsExceptions + if appConfig != nil { + exceptions = appConfig.GitOpsConfig.Exceptions + if exceptions.Labels && incoming.LabelsPresent { + return nil, errors.New( + `"labels" is excepted from GitOps management. Remove the "labels:" key from your GitOps file or disable the exception in Fleet settings.`) + } + if exceptions.Secrets && incoming.SecretsPresent { + return nil, errors.New( + `"secrets" is excepted from GitOps management. Remove the "secrets:" key from your GitOps file or disable the exception in Fleet settings.`) + } + if exceptions.Software && incoming.SoftwarePresent && incoming.TeamName != nil { + return nil, errors.New( + `"software" is excepted from GitOps management. Remove the "software:" key from your GitOps file or disable the exception in Fleet settings.`) + } + } + if incoming.TeamName == nil { // OrgSettings is the basis of the group AppConfig, but we will be adding and removing some // items because the GitOps structure is not the same as the AppConfig structure. @@ -1888,10 +1918,14 @@ func (c *Client) DoGitOps( // Enroll secrets are managed separately in Client.ApplyGroup, so we remove them from the // OrgSettings so that they are not applied as part of the AppConfig. - if orgSecrets, ok := incoming.OrgSettings["secrets"]; ok { - group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: orgSecrets.([]*fleet.EnrollSecret)} - delete(incoming.OrgSettings, "secrets") + // If secrets are not excepted and key is absent, treat as delete-all. + if !exceptions.Secrets && !incoming.SecretsPresent { + incoming.OrgSettings["secrets"] = make([]*fleet.EnrollSecret, 0) } + if orgSecrets, ok := incoming.OrgSettings["secrets"]; ok && !exceptions.Secrets { + group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: orgSecrets.([]*fleet.EnrollSecret)} + } + delete(incoming.OrgSettings, "secrets") // Certificate authorities are managed separately in Client.ApplyGroup, so we remove them from the // OrgSettings so that they are not applied as part of the AppConfig. @@ -1902,8 +1936,8 @@ func (c *Client) DoGitOps( group.CertificateAuthorities = groupedCAs delete(incoming.OrgSettings, "certificate_authorities") - // Labels - if incoming.Labels == nil || len(incoming.Labels) > 0 { + // Update labels if there were any changes. + if incoming.LabelChangesSummary.HasChanges() { err := c.doGitOpsLabels(incoming, logFn, dryRun) if err != nil { return nil, err @@ -2112,11 +2146,21 @@ func (c *Client) DoGitOps( team["features"] = features } team["scripts"] = scripts - team["software"] = map[string]any{} - team["software"].(map[string]any)["app_store_apps"] = incoming.Software.AppStoreApps - team["software"].(map[string]any)["packages"] = incoming.Software.Packages - team["software"].(map[string]any)["fleet_maintained_apps"] = incoming.Software.FleetMaintainedApps - if teamSecrets, ok := incoming.TeamSettings["secrets"]; ok { + // Software: skip if excepted (key already validated as absent above). + if !exceptions.Software { + team["software"] = map[string]any{} + team["software"].(map[string]any)["app_store_apps"] = incoming.Software.AppStoreApps + team["software"].(map[string]any)["packages"] = incoming.Software.Packages + team["software"].(map[string]any)["fleet_maintained_apps"] = incoming.Software.FleetMaintainedApps + } + // Secrets: if not excepted and key is absent, treat as delete-all. + if !exceptions.Secrets && !incoming.SecretsPresent { + if incoming.TeamSettings == nil { + incoming.TeamSettings = make(map[string]any) + } + incoming.TeamSettings["secrets"] = make([]*fleet.EnrollSecret, 0) + } + if teamSecrets, ok := incoming.TeamSettings["secrets"]; ok && !exceptions.Secrets { team["secrets"] = teamSecrets } @@ -2388,7 +2432,7 @@ func (c *Client) DoGitOps( teamVPPApps = teamsVPPApps[*incoming.TeamName] teamScripts = teamsScripts[*incoming.TeamName] } else { - noTeamSoftwareInstallers, noTeamVPPApps, err := c.doGitOpsNoTeamSetupAndSoftware(incoming, baseDir, appConfig, logFn, dryRun) + noTeamSoftwareInstallers, noTeamVPPApps, err := c.doGitOpsNoTeamSetupAndSoftware(incoming, baseDir, appConfig, exceptions.Software, logFn, dryRun) if err != nil { return nil, err } @@ -2396,8 +2440,15 @@ func (c *Client) DoGitOps( if err := c.doGitOpsNoTeamWebhookSettings(incoming, appConfig, logFn, dryRun); err != nil { return nil, fmt.Errorf("applying webhook settings for unassigned hosts: %w", err) } - teamSoftwareInstallers = noTeamSoftwareInstallers - teamVPPApps = noTeamVPPApps + if exceptions.Software && !incoming.SoftwarePresent { + // Software is excepted and not present in YAML — use pre-fetched data + // for policy validation instead of the empty data from the parser. + teamSoftwareInstallers = teamsSoftwareInstallers[*incoming.TeamName] + teamVPPApps = teamsVPPApps[*incoming.TeamName] + } else { + teamSoftwareInstallers = noTeamSoftwareInstallers + teamVPPApps = noTeamVPPApps + } teamScripts = teamsScripts["No team"] } } @@ -2502,6 +2553,7 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( config *spec.GitOps, baseDir string, appconfig *fleet.EnrichedAppConfig, + softwareExcepted bool, logFn func(format string, args ...interface{}), dryRun bool, ) ([]fleet.SoftwarePackageResponse, []fleet.VPPAppResponse, error) { @@ -2525,6 +2577,26 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( macosSetupScript = &fileContent{Filename: filepath.Base(macOSSetup.Script.Value), Content: b} } + // Apply the setup experience script regardless of software exception status, + // since it's part of controls, not software. + if !dryRun { + if macosSetupScript != nil { + logFn("[+] applying macos setup experience script for unassigned hosts\n") + if err := c.uploadMacOSSetupScript(macosSetupScript.Filename, macosSetupScript.Content, nil); err != nil { + return nil, nil, fmt.Errorf("uploading setup experience script for unassigned hosts: %w", err) + } + } else if err := c.deleteMacOSSetupScript(nil); err != nil { + return nil, nil, fmt.Errorf("deleting setup experience script for unassigned hosts: %w", err) + } + } + + // When software is excepted from GitOps, don't apply software for no-team + // (which would wipe existing software with an empty payload). The caller + // uses pre-fetched server data for policy validation instead. + if softwareExcepted && !config.SoftwarePresent { + return nil, nil, nil + } + noTeamSoftwareMacOSSetup, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, macOSSetup.Software.Value) if err != nil { return nil, nil, err @@ -2604,16 +2676,6 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( if err != nil { return nil, nil, fmt.Errorf("applying software installers: %w", err) } - if !dryRun { - if macosSetupScript != nil { - logFn("[+] applying macos setup experience script for unassigned hosts\n") - if err := c.uploadMacOSSetupScript(macosSetupScript.Filename, macosSetupScript.Content, nil); err != nil { - return nil, nil, fmt.Errorf("uploading setup experience script for unassigned hosts: %w", err) - } - } else if err := c.deleteMacOSSetupScript(nil); err != nil { - return nil, nil, fmt.Errorf("deleting setup experience script for unassigned hosts: %w", err) - } - } format := applyingTeamFormat if dryRun {