Add "exceptions" GitOps config (#42013)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42008 

# Details

Step one in https://github.com/fleetdm/fleet/issues/40171. 

This PR adds a new `exceptions` subsection to the current GitOps config,
with boolean keys for software, secrets and labels. For existing
instances a migration is included to set labels and secrets to `true`.
For new instances, only `secrets` will be `true`.

# Checklist for submitter

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

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

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually

(https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)
- [X] ran migration and verified that app config had `gitops.exceptions`
with `software: false, secrets: true, labels: true`
- [X] created a new instance and verified that that app config had
`gitops.exceptions` with `software: false, secrets: true, labels: false`
- [X] verified that the PATCH /config API works and can update
exceptions independently of other config

## Database migrations

- [X] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
n/a
- [X] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
n/a
- [X] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
n/a

## New Fleet configuration settings

- [X] Setting(s) is/are explicitly excluded from GitOps
these will not be set in GitOps, since they're _about_ how GitOps works.

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* GitOps configuration now supports exception settings for granular
resource control. Administrators can configure which specific resource
types (labels, software, and secrets) are included in or excluded from
GitOps mode operations.

* **Improvements**
* Improved GitOps configuration handling to preserve exception settings
during partial updates and system migrations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress 2026-03-23 10:47:17 -05:00 committed by GitHub
parent 7a6a95703f
commit deec6aa904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 187 additions and 30 deletions

View file

@ -375,9 +375,14 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
// Set a GitOps UI mode to verify that applying GitOps config won't overwrite it.
UIGitOpsMode: fleet.UIGitOpsModeConfig{
GitOpsConfig: fleet.GitOpsConfig{
GitopsModeEnabled: true,
RepositoryURL: "https://didsomeonesaygitops.biz",
Exceptions: fleet.GitOpsExceptions{
Software: false,
Secrets: true,
Labels: true,
},
},
}, nil
}
@ -577,8 +582,11 @@ software:
assert.Empty(t, enrolledSecrets)
// GitOps should not overwrite GitOps UI Mode.
assert.Equal(t, savedAppConfig.UIGitOpsMode.GitopsModeEnabled, true)
assert.Equal(t, savedAppConfig.UIGitOpsMode.RepositoryURL, "https://didsomeonesaygitops.biz")
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)
// Check MDM settings
require.True(t, savedAppConfig.MDM.EnableDiskEncryption.Value)

View file

@ -194,7 +194,12 @@
"scripts": null,
"gitops": {
"gitops_mode_enabled": false,
"repository_url": ""
"repository_url": "",
"exceptions": {
"labels": false,
"software": false,
"secrets": false
}
}
}
}

View file

@ -166,7 +166,12 @@
"scripts": null,
"gitops": {
"gitops_mode_enabled": false,
"repository_url": ""
"repository_url": "",
"exceptions": {
"labels": false,
"software": false,
"secrets": false
}
}
}
}

View file

@ -140,3 +140,7 @@ spec:
gitops:
gitops_mode_enabled: false
repository_url: ""
exceptions:
labels: false
software: false
secrets: false

View file

@ -166,3 +166,7 @@ spec:
gitops:
gitops_mode_enabled: false
repository_url: ""
exceptions:
labels: false
software: false
secrets: false

View file

@ -257,7 +257,12 @@
},
"gitops": {
"gitops_mode_enabled": false,
"repository_url": ""
"repository_url": "",
"exceptions": {
"labels": false,
"software": false,
"secrets": false
}
}
}
}

View file

@ -219,3 +219,7 @@ spec:
gitops:
gitops_mode_enabled: false
repository_url: ""
exceptions:
labels: false
software: false
secrets: false

View file

@ -285,7 +285,12 @@
},
"gitops": {
"gitops_mode_enabled": false,
"repository_url": "https://github.com/fleetdm/fleet/tree/main/it-and-security"
"repository_url": "https://github.com/fleetdm/fleet/tree/main/it-and-security",
"exceptions": {
"labels": false,
"software": false,
"secrets": false
}
},
"scripts": []
}

View file

@ -166,3 +166,7 @@ spec:
gitops:
gitops_mode_enabled: false
repository_url: ""
exceptions:
labels: false
software: false
secrets: false

View file

@ -166,3 +166,7 @@ spec:
gitops:
gitops_mode_enabled: false
repository_url: ""
exceptions:
labels: false
software: false
secrets: false

View file

@ -0,0 +1,26 @@
package tables
import (
"database/sql"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func init() {
MigrationClient.AddMigration(Up_20260323144117, Down_20260323144117)
}
func Up_20260323144117(tx *sql.Tx) error {
return updateAppConfigJSON(tx, func(config *fleet.AppConfig) error {
// For existing instances, preserve current implicit behavior:
// labels and secrets were already no-ops when omitted from GitOps.
config.GitOpsConfig.Exceptions.Labels = true
config.GitOpsConfig.Exceptions.Secrets = true
config.GitOpsConfig.Exceptions.Software = false
return nil
})
}
func Down_20260323144117(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,30 @@
package tables
import (
"encoding/json"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/require"
)
func TestUp_20260323144117(t *testing.T) {
db := applyUpToPrev(t)
// Apply the migration
applyNext(t, db)
// Verify exceptions were set correctly for existing instance
var rawAppConfig []byte
err := db.QueryRow(`SELECT json_value FROM app_config_json WHERE id = 1`).Scan(&rawAppConfig)
require.NoError(t, err)
var config fleet.AppConfig
err = json.Unmarshal(rawAppConfig, &config)
require.NoError(t, err)
// Existing instances should have labels and secrets excepted (preserving current behavior)
require.True(t, config.GitOpsConfig.Exceptions.Labels)
require.True(t, config.GitOpsConfig.Exceptions.Secrets)
require.False(t, config.GitOpsConfig.Exceptions.Software)
}

File diff suppressed because one or more lines are too long

View file

@ -273,9 +273,16 @@ type DiskEncryptionConfig struct {
BitLockerPINRequired bool
}
type UIGitOpsModeConfig struct {
GitopsModeEnabled bool `json:"gitops_mode_enabled"`
RepositoryURL string `json:"repository_url"`
type GitOpsExceptions struct {
Labels bool `json:"labels"`
Software bool `json:"software"`
Secrets bool `json:"secrets"`
}
type GitOpsConfig struct {
GitopsModeEnabled bool `json:"gitops_mode_enabled"`
RepositoryURL string `json:"repository_url"`
Exceptions GitOpsExceptions `json:"exceptions"`
}
func (c *AppConfig) MDMUrl() string {
@ -673,7 +680,7 @@ type AppConfig struct {
MDM MDM `json:"mdm"`
UIGitOpsMode UIGitOpsModeConfig `json:"gitops"`
GitOpsConfig GitOpsConfig `json:"gitops"`
// Scripts is a slice of script file paths.
//
@ -1064,6 +1071,10 @@ func (c *AppConfig) ApplyDefaultsForNewInstalls() {
c.Features.ApplyDefaultsForNewInstalls()
c.GitOpsConfig.Exceptions.Secrets = true
c.GitOpsConfig.Exceptions.Labels = false
c.GitOpsConfig.Exceptions.Software = false
c.ApplyDefaults()
}

View file

@ -225,7 +225,7 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
Integrations: appConfig.Integrations,
MDM: appConfig.MDM,
Scripts: appConfig.Scripts,
UIGitOpsMode: appConfig.UIGitOpsMode,
GitOpsConfig: appConfig.GitOpsConfig,
ConditionalAccess: appConfig.ConditionalAccess,
},
appConfigResponseFields: appConfigResponseFields{
@ -809,7 +809,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
appConfig.Integrations.GoogleCalendar = oldAppConfig.Integrations.GoogleCalendar
}
gitopsModeEnabled, gitopsRepoURL := appConfig.UIGitOpsMode.GitopsModeEnabled, appConfig.UIGitOpsMode.RepositoryURL
gitopsModeEnabled, gitopsRepoURL := appConfig.GitOpsConfig.GitopsModeEnabled, appConfig.GitOpsConfig.RepositoryURL
if gitopsModeEnabled {
if !lic.IsPremium() {
return nil, fleet.NewInvalidArgumentError("UI GitOpsMode: ", ErrMissingLicense.Error())
@ -826,7 +826,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
}
}
if oldAppConfig.UIGitOpsMode.GitopsModeEnabled != appConfig.UIGitOpsMode.GitopsModeEnabled {
if oldAppConfig.GitOpsConfig.GitopsModeEnabled != appConfig.GitOpsConfig.GitopsModeEnabled {
// generate the activity
var act fleet.ActivityDetails
if gitopsModeEnabled {

View file

@ -676,10 +676,7 @@ func (c *Client) ApplyGroup(
// Keep any existing GitOps mode config rather than attempting to set via GitOps.
if appconfig != nil {
specs.AppConfig.(map[string]interface{})["gitops"] = fleet.UIGitOpsModeConfig{
GitopsModeEnabled: appconfig.UIGitOpsMode.GitopsModeEnabled,
RepositoryURL: appconfig.UIGitOpsMode.RepositoryURL,
}
specs.AppConfig.(map[string]any)["gitops"] = appconfig.GitOpsConfig
}
if err := c.ApplyAppConfig(specs.AppConfig, opts.ApplySpecOptions); err != nil {

View file

@ -7736,8 +7736,8 @@ func (s *integrationTestSuite) TestAppConfig() {
assert.False(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled)
assert.Zero(t, acResp.ActivityExpirySettings.ActivityExpiryWindow)
assert.False(t, acResp.ServerSettings.AIFeaturesDisabled)
assert.False(t, acResp.UIGitOpsMode.GitopsModeEnabled)
assert.Zero(t, acResp.UIGitOpsMode.RepositoryURL)
assert.False(t, acResp.GitOpsConfig.GitopsModeEnabled)
assert.Zero(t, acResp.GitOpsConfig.RepositoryURL)
// set the apple BM terms expired flag, and the enabled and configured flags,
// we'll check again at the end of this test to make sure they weren't

View file

@ -3334,7 +3334,51 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsModeConfig() {
}`), http.StatusOK)
config, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://a.b.cc", config.UIGitOpsMode.RepositoryURL)
assert.Equal(t, "https://a.b.cc", config.GitOpsConfig.RepositoryURL)
}
func (s *integrationEnterpriseTestSuite) TestGitOpsExceptionsConfig() {
t := s.T()
// Enable GitOps mode first
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "gitops_mode_enabled": true, "repository_url": "https://example.com/repo" }
}`), http.StatusOK)
// Set exceptions
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "exceptions": { "labels": true, "software": true, "secrets": false } }
}`), http.StatusOK)
config, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
assert.True(t, config.GitOpsConfig.Exceptions.Labels)
assert.True(t, config.GitOpsConfig.Exceptions.Software)
assert.False(t, config.GitOpsConfig.Exceptions.Secrets)
assert.True(t, config.GitOpsConfig.GitopsModeEnabled)
assert.Equal(t, "https://example.com/repo", config.GitOpsConfig.RepositoryURL)
// Partial update — only change one exception, others should persist
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "exceptions": { "software": false } }
}`), http.StatusOK)
config, err = s.ds.AppConfig(context.Background())
require.NoError(t, err)
assert.True(t, config.GitOpsConfig.Exceptions.Labels, "labels should persist")
assert.False(t, config.GitOpsConfig.Exceptions.Software, "software should be updated")
assert.False(t, config.GitOpsConfig.Exceptions.Secrets, "secrets should persist")
assert.True(t, config.GitOpsConfig.GitopsModeEnabled)
assert.Equal(t, "https://example.com/repo", config.GitOpsConfig.RepositoryURL)
// Verify exceptions appear in GET response
var getResp appConfigResponse
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &getResp)
assert.True(t, getResp.GitOpsConfig.Exceptions.Labels)
assert.False(t, getResp.GitOpsConfig.Exceptions.Software)
assert.False(t, getResp.GitOpsConfig.Exceptions.Secrets)
assert.True(t, getResp.GitOpsConfig.GitopsModeEnabled)
assert.Equal(t, "https://example.com/repo", getResp.GitOpsConfig.RepositoryURL)
}
func (s *integrationEnterpriseTestSuite) assertAppleOSUpdatesDeclaration(teamID *uint, profileName string, expected *fleet.AppleOSUpdateSettings) {
@ -12363,7 +12407,6 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
PreInstallQuery: "some pre install query",
@ -12381,7 +12424,6 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
}
s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`)
})
}
@ -13786,7 +13828,6 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
}
res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
assert.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`)
})
}

View file

@ -193,9 +193,13 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server
github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec Name string
github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec CertificateAuthorityName string
github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectName string
github.com/fleetdm/fleet/v4/server/fleet/AppConfig UIGitOpsMode fleet.UIGitOpsModeConfig
github.com/fleetdm/fleet/v4/server/fleet/UIGitOpsModeConfig GitopsModeEnabled bool
github.com/fleetdm/fleet/v4/server/fleet/UIGitOpsModeConfig RepositoryURL string
github.com/fleetdm/fleet/v4/server/fleet/AppConfig GitOpsConfig fleet.GitOpsConfig
github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig GitopsModeEnabled bool
github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig RepositoryURL string
github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig Exceptions fleet.GitOpsExceptions
github.com/fleetdm/fleet/v4/server/fleet/GitOpsExceptions Labels bool
github.com/fleetdm/fleet/v4/server/fleet/GitOpsExceptions Software bool
github.com/fleetdm/fleet/v4/server/fleet/GitOpsExceptions Secrets bool
github.com/fleetdm/fleet/v4/server/fleet/AppConfig Scripts optjson.Slice[string]
github.com/fleetdm/fleet/v4/server/fleet/AppConfig YaraRules []fleet.YaraRule
github.com/fleetdm/fleet/v4/server/fleet/YaraRule Name string