Make most GitOps top-level optional (#41138)

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

# Details

This PR makes it allowable to leave out almost all top-level keys from
GitOps files. The only required keys are _either_ `name:` (for a fleet
settings file) or `org_settings:` (for a global settings file). Omitting
a key is identical to supplying it with no value.

# Checklist for submitter

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

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

## Testing

- [X] Added/updated automated tests
Updated the "missing all global keys test", and added some new tests to
verify that omitting the key was the same as supplying it with an empty
value.
- [X] QA'd all new/changed functionality manually
1. Ran `fleetctl generate-gitops` to get a clean set of GitOps yml files
2. Removed all removable keys from default.yml and ran `fleetctl gitops`
3. Ran `fleetctl generate-gitops` again into a different dir
4. Ran `fleetctl gitops` with the original files to get back to original
state
5. Cleared out all now-removable keys and replaced them with empty value
(e.g. `reports:` with nothing under it)
6. Ran `fleetctl generate-gitops` again into a third dir
7. Compared the files from the second and third generate-gitops runs to
verify that omitting the key had the same result as supplying it with an
empty value
8. Did the above steps with a fleet (i.e. non-global) .yml file.

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* GitOps files now support omitting top-level configuration keys instead
of requiring them to be explicitly set to empty values.
  * org_settings is now required when team name is not specified.

* **Tests**
* Added integration tests validating behavior when omitting top-level
keys in global and team-level GitOps configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress 2026-03-09 08:56:05 -05:00 committed by GitHub
parent 49b7db18fa
commit 9c4d5ce97e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 291 additions and 19 deletions

View file

@ -0,0 +1 @@
- Most top-level keys can now be omitted from GitOps files in place of supplying them with an empty value.

View file

@ -201,7 +201,7 @@ org_settings:
require.NoError(t, err)
_, err = RunAppNoChecks([]string{"gitops", "-f", badFile.Name()})
require.Error(t, err)
assert.Contains(t, err.Error(), "errors occurred")
assert.Contains(t, err.Error(), "'org_settings' is required")
// DoGitOps error
t.Setenv("ORG_NAME", "")
@ -209,7 +209,7 @@ org_settings:
require.Error(t, err)
assert.Contains(t, err.Error(), "organization name must be present")
// Missing controls.
// Missing controls is now allowed (controls is optional).
tmpFile2, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = tmpFile2.WriteString(
@ -229,8 +229,8 @@ org_settings:
)
require.NoError(t, err)
_, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile2.Name()})
require.Error(t, err)
assert.Equal(t, `'controls' must be set on global config`, err.Error())
require.NoError(t, err)
savedAppConfig = &fleet.AppConfig{}
// Dry run
t.Setenv("ORG_NAME", orgName)
@ -2262,7 +2262,7 @@ software:
assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on policies included in `no-team.yml`: \"Foobar\""), err.Error())
})
t.Run("global and no-team.yml DO NOT define controls -- should fail", func(t *testing.T) {
t.Run("global and no-team.yml DO NOT define controls -- controls is now optional", func(t *testing.T) {
globalFileWithoutControlsAndSoftwareKeys := createGlobalFileWithoutControlsAndSoftwareKeys(t, fleetServerURL, orgName)
noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml")
@ -2275,20 +2275,16 @@ software:
`)
require.NoError(t, err)
// Dry run, controls should be defined somewhere, either in no-team.yml or global.
_, err = RunAppNoChecks([]string{
// Dry run, controls is now optional so this should succeed.
_ = RunAppForTest(t, []string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
// Real run
_, err = RunAppNoChecks([]string{
_ = RunAppForTest(t, []string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
})
t.Run("controls only defined in no-team.yml", func(t *testing.T) {

View file

@ -3753,3 +3753,249 @@ settings:
require.NotNil(t, tmPols[0].SoftwareInstallerID)
require.Equal(t, installer.InstallerID, *tmPols[0].SoftwareInstallerID)
}
// TestOmittedTopLevelKeysGlobal verifies that omitting top-level keys from a global
// gitops file clears the corresponding settings (e.g. policies, agent_options).
func (s *enterpriseIntegrationGitopsTestSuite) TestOmittedTopLevelKeysGlobal() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
// Step 1: Apply a full global config with policies, agent_options, controls, and reports.
const fullGlobalConfig = `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: boofar
agent_options:
config:
options:
pack_delimiter: /
controls:
enable_disk_encryption: true
policies:
- name: Test Global Policy
query: SELECT 1;
reports:
- name: Test Global Report
query: SELECT 1;
automations_enabled: false
labels:
- name: Test Global Label
label_membership_type: dynamic
query: SELECT 1
`
fullFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fullFile.WriteString(fullGlobalConfig)
require.NoError(t, err)
require.NoError(t, fullFile.Close())
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fullFile.Name()}))
// Verify policy, agent_options, controls, and reports were applied.
policies, err := s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, "Test Global Policy", policies[0].Name)
appCfg, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
require.NotNil(t, appCfg.AgentOptions)
require.Contains(t, string(*appCfg.AgentOptions), "pack_delimiter")
require.True(t, appCfg.MDM.EnableDiskEncryption.Value)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, "Test Global Report", queries[0].Name)
globalSecrets, err := s.DS.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, globalSecrets, 1)
require.Equal(t, "boofar", globalSecrets[0].Secret)
labels, err := s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
// Step 2: Apply a minimal global config that omits policies, agent_options, controls, reports, labels.
const minimalGlobalConfig = `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
`
minimalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = minimalFile.WriteString(minimalGlobalConfig)
require.NoError(t, err)
require.NoError(t, minimalFile.Close())
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", minimalFile.Name()}))
// Verify policies were cleared.
policies, err = s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, policies, 0)
appCfg, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
// Verify agent_options were cleared (set to null).
require.Nil(t, appCfg.AgentOptions)
// Verify controls were cleared (disk encryption reverts to false).
require.False(t, appCfg.MDM.EnableDiskEncryption.Value)
// Verify reports were cleared.
queries, _, _, _, err = s.DS.ListQueries(ctx, fleet.ListQueryOptions{})
require.NoError(t, err)
require.Len(t, queries, 0)
// Verify secrets are unchanged.
globalSecrets, err = s.DS.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, globalSecrets, 1)
require.Equal(t, "boofar", globalSecrets[0].Secret)
// Verify labels are unchanged.
labels, err = s.DS.LabelsByName(ctx, []string{"Test Global Label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
}
// TestOmittedTopLevelKeysFleet verifies that omitting top-level keys from a fleet
// gitops file clears the corresponding settings (e.g. policies, agent_options, settings).
func (s *enterpriseIntegrationGitopsTestSuite) TestOmittedTopLevelKeysFleet() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
fleetName := "Test Omitted Keys " + uuid.NewString()
// Step 1: Apply a full fleet config with policies, agent_options, controls, features, reports, and software.
fullFleetConfig := fmt.Sprintf(`
name: %s
settings:
secrets:
- secret: foobar
features:
enable_host_users: false
agent_options:
config:
options:
pack_delimiter: /
controls:
enable_disk_encryption: true
policies:
- name: Test Fleet Policy
query: SELECT 1;
reports:
- name: Test Fleet Report
query: SELECT 1;
automations_enabled: false
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
`, fleetName)
fullFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fullFleetFile.WriteString(fullFleetConfig)
require.NoError(t, err)
require.NoError(t, fullFleetFile.Close())
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", fullFleetFile.Name(),
}))
// Verify policy, agent_options, controls, features, and reports were applied.
fl, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
flPols, err := s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, flPols, 1)
require.Equal(t, "Test Fleet Policy", flPols[0].Name)
require.NotNil(t, fl.Config.AgentOptions)
require.Contains(t, string(*fl.Config.AgentOptions), "pack_delimiter")
require.True(t, fl.Config.MDM.EnableDiskEncryption)
require.False(t, fl.Config.Features.EnableHostUsers)
flQueries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &fl.ID})
require.NoError(t, err)
require.Len(t, flQueries, 1)
require.Equal(t, "Test Fleet Report", flQueries[0].Name)
flSecrets, err := s.DS.GetEnrollSecrets(ctx, &fl.ID)
require.NoError(t, err)
require.Len(t, flSecrets, 1)
require.Equal(t, "foobar", flSecrets[0].Secret)
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, 1)
// Step 2: Apply a minimal fleet config that omits policies, agent_options, controls, reports, software, settings.
minimalFleetConfig := fmt.Sprintf(`
name: %s
`, fleetName)
minimalFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = minimalFleetFile.WriteString(minimalFleetConfig)
require.NoError(t, err)
require.NoError(t, minimalFleetFile.Close())
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{
"gitops", "--config", fleetctlConfig.Name(), "-f", minimalFleetFile.Name(),
}))
// Verify policies were cleared.
flPols, err = s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, flPols, 0)
fl, err = s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
// Verify agent_options were cleared (set to null).
require.Nil(t, fl.Config.AgentOptions)
// Verify controls were cleared (disk encryption reverts to false).
require.False(t, fl.Config.MDM.EnableDiskEncryption)
// Verify features reverted to defaults (enable_host_users defaults to true).
require.True(t, fl.Config.Features.EnableHostUsers)
// Verify reports were cleared.
flQueries, _, _, _, err = s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &fl.ID})
require.NoError(t, err)
require.Len(t, flQueries, 0)
// Verify secrets are unchanged.
flSecrets, err = s.DS.GetEnrollSecrets(ctx, &fl.ID)
require.NoError(t, err)
require.Len(t, flSecrets, 1)
require.Equal(t, "foobar", flSecrets[0].Secret)
// 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)
}

View file

@ -342,6 +342,11 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
if err := json.Unmarshal(updatedBytes, &top); err != nil {
return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err)
}
// This should never happen since we don't support empty yaml files,
// but adding for defensive purposes.
if top == nil {
top = make(map[string]json.RawMessage)
}
var multiError *multierror.Error
result := &GitOps{}
@ -391,14 +396,32 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
multiError = parseNoTeamSettings(settingsRaw, result, filePath, multiError)
}
default:
// Allow omitting settings key for teams, clearing all team settings as a result.
if !settingsOk {
multiError = multierror.Append(multiError, errors.New("'settings' is required when 'name' is provided"))
} else {
multiError = parseTeamSettings(settingsRaw, result, baseDir, filePath, multiError)
settingsRaw = json.RawMessage("null")
}
multiError = parseTeamSettings(settingsRaw, result, baseDir, filePath, multiError)
}
default:
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'settings' is required"))
multiError = multierror.Append(multiError, errors.New("if `name` is not provided, 'org_settings' is required"))
}
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" {
continue
}
// "agent_options" and "reports" are not supported in no-team/unassigned files.
if result.IsNoTeam() && (topKey == "agent_options" || topKey == "reports") {
continue
}
// Default top keys to null if not present.
// This will clear the settings as if the key was provided with an empty value.
if _, ok := top[topKey]; !ok {
top[topKey] = json.RawMessage("null")
}
}
// Get the labels. If `labels:` is specified but no labels are listed, this will

View file

@ -606,10 +606,10 @@ func TestInvalidGitOpsYaml(t *testing.T) {
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "must have a 'secret' key")
// Missing settings (formerly settings).
// Missing settings is now allowed (defaults to null, clearing team settings).
config = getConfig([]string{"settings"})
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'settings' is required when 'name' is provided")
assert.NoError(t, err)
// settings is now allowed on "no-team.yml" for webhook settings
config = getConfig([]string{"name", "settings"}) // Exclude settings with secrets
@ -913,17 +913,22 @@ func TestTopLevelGitOpsValidation(t *testing.T) {
shouldPass: true,
isTeam: true,
},
"missing_all": {
// Top-level keys besides "name" and "org_settings" are now optional.
// A file must have either "name" (team) or "org_settings" (global).
"missing_all_global": {
optsToExclude: []string{"controls", "reports", "policies", "agent_options", "org_settings"},
},
"missing_reports": {
optsToExclude: []string{"reports"},
shouldPass: true,
},
"missing_policies": {
optsToExclude: []string{"policies"},
shouldPass: true,
},
"missing_agent_options": {
optsToExclude: []string{"agent_options"},
shouldPass: true,
},
"missing_org_settings": {
optsToExclude: []string{"org_settings"},
@ -934,6 +939,7 @@ func TestTopLevelGitOpsValidation(t *testing.T) {
},
"missing_settings": {
optsToExclude: []string{"settings"},
shouldPass: true,
isTeam: true,
},
}