require controls on either global or no-team (#41350)

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

# Details

* Fixes a potential issue where running `fleetctl gitops` with only the
global file, with no controls provided, could wipe out global controls
that are provided in the "no team" file.
* Fixes error message when controls are missing.

# Checklist for submitter

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
- [x] `fleetctl gitops -f /path/to/default.yml` without controls, gives
`error: 'controls' must be set on global config, no-team.yml or
unassigned.yml`
- [x] `fleetctl gitops -f /path/to/default.yml` with empty controls
works
- [x] `fleetctl gitops -f /path/to/default.yml -f /path/to/no-team.yml`
without controls, gives `error: 'controls' must be set on global config
or no-team.yml`
- [x] `fleetctl gitops -f /path/to/default.yml -f
/path/to/unassigned.yml` without controls, gives `error: 'controls' must
be set on global config or unassigned.yml`
- [x] `fleetctl gitops -f /path/to/default.yml -f /path/to/no-team.yml`
with empty controls in no-team.yml works
- [x] `fleetctl gitops -f /path/to/default.yml -f
/path/to/unassigned.yml` with empty controls in unassigned.yml works
- [x] `fleetctl gitops -f /path/to/no-team.yml` gives error `global
config must be provided alongside no-team.yml`
- [x] `fleetctl gitops -f /path/to/no-team.yml` gives error `global
config must be provided alongside unassigned.yml`
- [x] `fleetctl gitops -f /path/to/some-real-team.yml` with no controls
works

For unreleased bug fixes in a release candidate, one of:

- [X] Confirmed that the fix is not expected to adversely impact load
test results
This commit is contained in:
Scott Gress
2026-03-10 14:01:18 -05:00
committed by GitHub
parent 94288fd555
commit 63be71fd72
4 changed files with 95 additions and 6 deletions
+5 -1
View File
@@ -303,7 +303,11 @@ func gitopsCommand() *cli.Command {
}
if !noTeamControls.Defined && !config.Controls.Defined {
if appConfig.License.IsPremium() {
return fmt.Errorf("'controls' must be set on global config or %s", noTeamFilename)
suggestion := ", no-team.yml or unassigned.yml"
if noTeamFilename != "" {
suggestion = fmt.Sprintf(" or %s", noTeamFilename)
}
return fmt.Errorf("'controls' must be set on global config%s", suggestion)
}
return errors.New("'controls' must be set on global config")
}
+83 -4
View File
@@ -218,6 +218,7 @@ queries:
policies:
agent_options:
labels:
controls:
org_settings:
server_settings:
server_url: https://example.com
@@ -2396,7 +2397,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 -- controls is now optional", func(t *testing.T) {
t.Run("global and no-team.yml DO NOT define controls -- should fail", func(t *testing.T) {
globalFileWithoutControlsAndSoftwareKeys := createGlobalFileWithoutControlsAndSoftwareKeys(t, fleetServerURL, orgName)
noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml")
@@ -2409,16 +2410,94 @@ software:
`)
require.NoError(t, err)
// Dry run, controls is now optional so this should succeed.
_ = RunAppForTest(t, []string{
// Dry run.
_, err = RunAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "must be set on global config or no-team.yml"), err.Error())
// Real run
_ = RunAppForTest(t, []string{
_, err = RunAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "must be set on global config or no-team.yml"), err.Error())
})
t.Run("global DOES NOT define controls -- should fail", func(t *testing.T) {
globalFileWithoutControlsAndSoftwareKeys := createGlobalFileWithoutControlsAndSoftwareKeys(t, fleetServerURL, orgName)
// Dry run.
_, err = RunAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "must be set on global config, no-team.yml or unassigned.yml"), err.Error())
// Real run
_, err = RunAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "must be set on global config, no-team.yml or unassigned.yml"), err.Error())
})
t.Run("no-team provided without global -- should fail", func(t *testing.T) {
noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml")
noTeamFileWithoutControls, err := os.Create(noTeamFilePathWithoutControls)
require.NoError(t, err)
_, err = noTeamFileWithoutControls.WriteString(`
policies:
name: No team
software:
`)
require.NoError(t, err)
// Dry run.
_, err = RunAppNoChecks([]string{
"gitops", "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "global config must be provided alongside no-team.yml"), err.Error())
_, err = RunAppNoChecks([]string{
"gitops", "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "global config must be provided alongside no-team.yml"), err.Error())
})
t.Run("unassigned provided without global -- should fail", func(t *testing.T) {
noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "unassigned.yml")
noTeamFileWithoutControls, err := os.Create(noTeamFilePathWithoutControls)
require.NoError(t, err)
_, err = noTeamFileWithoutControls.WriteString(`
policies:
name: Unassigned
software:
`)
require.NoError(t, err)
// Dry run
_, err = RunAppNoChecks([]string{
"gitops", "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "global config must be provided alongside unassigned.yml"), err.Error())
// Real run
_, err = RunAppNoChecks([]string{
"gitops", "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "global config must be provided alongside unassigned.yml"), err.Error())
})
t.Run("controls only defined in no-team.yml", func(t *testing.T) {
@@ -3825,8 +3825,9 @@ labels:
require.NoError(t, err)
require.Len(t, labels, 1)
// Step 2: Apply a minimal global config that omits policies, agent_options, controls, reports, labels.
// Step 2: Apply a minimal global config that omits policies, agent_options, reports, labels.
const minimalGlobalConfig = `
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
+5
View File
@@ -432,6 +432,11 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
if topKey == "name" || topKey == "labels" || 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
// files aren't supplied, so play it safe and require it to be set on one or the other.
if (result.IsNoTeam() || result.IsGlobal()) && topKey == "controls" {
continue
}
// "agent_options" and "reports" are not supported in no-team/unassigned files.
if result.IsNoTeam() && (topKey == "agent_options" || topKey == "reports") {
continue