GitOps: combined include/exclude policy label targeting + labels_exclude_all (#33441) (#47505)

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

# Checklist for submitter

- [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.
(Already added in main.)

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

`generate-gitops`:



https://github.com/user-attachments/assets/d32e89c3-2ce7-4c57-9492-66deb0a3dfe8



`gitops`:



https://github.com/user-attachments/assets/ac0e3935-bc8d-4541-b3e5-f992109630d9




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

## Summary by CodeRabbit

* **New Features**
* Added `labels_exclude_all` field support for refining policy label
scopes (available with Fleet Premium license).

* **Bug Fixes**
* Enhanced validation of policy label scope configurations to prevent
invalid field combinations and enforce license requirements.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nico
2026-06-15 08:55:05 -03:00
committed by GitHub
parent 6d997bdfda
commit 5e27628266
9 changed files with 270 additions and 26 deletions
+3
View File
@@ -1674,6 +1674,9 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string
if policy.LabelsExcludeAny != nil {
policySpec["labels_exclude_any"] = fleet.LabelIdentsToNames(policy.LabelsExcludeAny)
}
if policy.LabelsExcludeAll != nil && cmd.AppConfig.License.IsPremium() {
policySpec["labels_exclude_all"] = fleet.LabelIdentsToNames(policy.LabelsExcludeAll)
}
result[i] = policySpec
}
return result, nil
@@ -381,6 +381,9 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
}, {
LabelName: "Label D",
}},
LabelsExcludeAll: []fleet.LabelIdent{{
LabelName: "Label E",
}},
Type: fleet.PolicyTypeDynamic,
},
},
+16 -15
View File
@@ -395,8 +395,8 @@ func gitopsCommand() *cli.Command {
}
}
// Targeting queries against labels is a Premium feature only
if !appConfig.License.IsPremium() {
// Targeting queries against labels is a Premium feature only
for _, query := range config.Queries {
if len(query.LabelsIncludeAny) > 0 {
return fmt.Errorf("report %q uses 'labels_include_any', which is only available in Fleet Premium", query.Name)
@@ -405,6 +405,15 @@ func gitopsCommand() *cli.Command {
return fmt.Errorf("report %q uses 'labels_include_all', which is only available in Fleet Premium", query.Name)
}
}
// TODO(nulmete): might need to revisit if just this scopes are premium-only or all of them (include_any and exclude_any)
for _, policy := range config.Policies {
if len(policy.LabelsIncludeAll) > 0 {
return fmt.Errorf("policy %q uses 'labels_include_all', which is only available in Fleet Premium", policy.Name)
}
if len(policy.LabelsExcludeAll) > 0 {
return fmt.Errorf("policy %q uses 'labels_exclude_all', which is only available in Fleet Premium", policy.Name)
}
}
}
// Gather stats on where labels are used in this gitops config,
@@ -1054,22 +1063,14 @@ func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) {
updateLabelUsage(labels, query.Name, "Query", result)
}
// Get policy label usage.
// Get policy label usage. A policy may combine one include scope (any/all)
// with one exclude scope (any/all); VerifyLabelScopes rejects more than one
// of either, or a label appearing in both an include and an exclude list.
for _, policy := range config.Policies {
nonEmptyScopes := 0
if len(policy.LabelsIncludeAny) > 0 {
nonEmptyScopes++
if err := policy.VerifyLabelScopes(); err != nil {
return nil, fmt.Errorf("Policy '%s': %w", policy.Name, err)
}
if len(policy.LabelsIncludeAll) > 0 {
nonEmptyScopes++
}
if len(policy.LabelsExcludeAny) > 0 {
nonEmptyScopes++
}
if nonEmptyScopes > 1 {
return nil, fmt.Errorf("Policy '%s' has multiple label keys; please choose one of `labels_include_any`, `labels_include_all`, or `labels_exclude_any`.", policy.Name)
}
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny)
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny, policy.LabelsExcludeAll)
updateLabelUsage(labels, policy.Name, "Policy", result)
}
+103 -4
View File
@@ -7163,18 +7163,64 @@ policies:
query: SELECT 1
resolution: ""
platform: linux
labels_include_all:
labels_include_any:
- lbl-a
labels_exclude_any:
labels_include_all:
- lbl-b
`)
require.NoError(t, err)
// A policy may combine one include scope with one exclude scope, but not two
// include scopes; this should be rejected before any API call is made.
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
require.Error(t, err)
require.ErrorContains(t, err, "bad-policy")
require.ErrorContains(t, err, "labels_include_all")
require.ErrorContains(t, err, "labels_exclude_any")
require.ErrorContains(t, err, "at most one of labels_include_any or labels_include_all")
}
func TestGitOpsPolicyLabelsExcludeAllRequiresPremium(t *testing.T) {
license := &fleet.LicenseInfo{Tier: fleet.TierFree}
_, ds := testing_utils.RunServerWithMockedDS(t, &service.TestServerOpts{License: license})
setupEmptyGitOpsMocks(ds)
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString(`
controls:
queries:
agent_options:
labels:
- name: lbl-a
description: A
label_membership_type: dynamic
query: SELECT 1
org_settings:
server_settings:
server_url: https://fleet.example.com
org_info:
contact_url: https://example.com/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: GitOps Test
secrets:
policies:
- name: premium-policy
description: uses premium scope
query: SELECT 1
resolution: ""
platform: linux
labels_exclude_all:
- lbl-a
`)
require.NoError(t, err)
// labels_exclude_all is a Premium-only scope for policies; a free-tier apply
// is rejected with a friendly pre-flight error before any policy is created.
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
require.Error(t, err)
require.ErrorContains(t, err, "premium-policy")
require.ErrorContains(t, err, "labels_exclude_all")
require.ErrorContains(t, err, "Fleet Premium")
}
func TestGitOpsScriptsLogging(t *testing.T) {
@@ -7927,3 +7973,56 @@ func TestValidateGitOpsGroupEUA(t *testing.T) {
assert.NoError(t, err)
})
}
func TestGetLabelUsagePolicyScopes(t *testing.T) {
testCases := []struct {
name string
policy fleet.PolicySpec
wantErrContains string
wantLabels []string // labels that must be tracked when no error is expected
}{
{
name: "include_any + exclude_any combined",
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsExcludeAny: []string{"b"}},
wantLabels: []string{"a", "b"},
},
{
name: "include_all + exclude_all combined",
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAll: []string{"a"}, LabelsExcludeAll: []string{"b"}},
wantLabels: []string{"a", "b"},
},
{
name: "two include scopes rejected",
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsIncludeAll: []string{"b"}},
wantErrContains: "at most one of labels_include_any or labels_include_all",
},
{
name: "two exclude scopes rejected",
policy: fleet.PolicySpec{Name: "p", LabelsExcludeAny: []string{"a"}, LabelsExcludeAll: []string{"b"}},
wantErrContains: "at most one of labels_exclude_any or labels_exclude_all",
},
{
name: "overlap between include and exclude rejected",
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsExcludeAll: []string{"a"}},
wantErrContains: `label "a" cannot appear in both an include and an exclude list`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
usage, err := getLabelUsage(&spec.GitOps{
Policies: []*spec.GitOpsPolicySpec{{PolicySpec: tc.policy}},
})
if tc.wantErrContains != "" {
require.ErrorContains(t, err, tc.wantErrContains)
return
}
require.NoError(t, err)
// Both include and exclude labels must be tracked as in-use so a
// referenced label can't be silently deleted.
for _, l := range tc.wantLabels {
require.Contains(t, usage, l)
}
})
}
}
@@ -22,6 +22,8 @@
labels_include_all:
- Label C
- Label D
labels_exclude_all:
- Label E
name: Global Policy Include All
platform: darwin
query: SELECT * FROM global_policy WHERE id = 2
@@ -200,6 +200,8 @@ policies:
continuous_automations_enabled: false
critical: false
description: This is a global policy with include_all scope
labels_exclude_all:
- Label E
labels_include_all:
- Label C
- Label D
@@ -5006,6 +5006,142 @@ org_settings:
require.Len(t, labels, 0)
}
// TestGitOpsPolicyLabelScopes verifies that policies applied via GitOps can combine
// one include scope (any/all) with one exclude scope (any/all) — including the premium
// labels_exclude_all scope — and that conflicting include/exclude scopes are rejected.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsPolicyLabelScopes() {
t := s.T()
ctx := t.Context()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
t.Setenv("FLEET_URL", s.Server.URL)
tempDir := t.TempDir()
fleetName := "Label Scopes " + uuid.NewString()
// The global file defines the labels referenced by both the global and team
// policies, plus a global policy that combines an include scope (any) with the
// premium exclude scope (all).
globalConfig := `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: label_scopes_secret
agent_options:
controls:
reports:
labels:
- name: Label A
label_membership_type: dynamic
query: SELECT 1
- name: Label B
label_membership_type: dynamic
query: SELECT 1
- name: Label C
label_membership_type: dynamic
query: SELECT 1
policies:
- name: Global Combined Scope
query: SELECT 1;
labels_include_any:
- Label A
labels_exclude_all:
- Label B
- Label C
`
globalFile := filepath.Join(tempDir, "global.yml")
require.NoError(t, os.WriteFile(globalFile, []byte(globalConfig), 0o644)) //nolint:gosec
// The team file defines a team policy that combines an include scope (all) with
// an exclude scope (any), referencing the globally-defined labels.
teamConfig := fmt.Sprintf(`
name: %s
controls:
software:
reports:
agent_options:
settings:
secrets:
- secret: label_scopes_team_secret
policies:
- name: Team Combined Scope
query: SELECT 1;
labels_include_all:
- Label A
- Label B
labels_exclude_any:
- Label C
`, fleetName)
teamFile := filepath.Join(tempDir, "team.yml")
require.NoError(t, os.WriteFile(teamFile, []byte(teamConfig), 0o644)) //nolint:gosec
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", teamFile}))
// The global policy persisted both its include_any and exclude_all scopes.
globalPolicies, err := s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, globalPolicies, 1)
gp := globalPolicies[0]
require.Equal(t, "Global Combined Scope", gp.Name)
require.Len(t, gp.LabelsIncludeAny, 1)
require.Equal(t, "Label A", gp.LabelsIncludeAny[0].LabelName)
require.Empty(t, gp.LabelsIncludeAll)
require.Empty(t, gp.LabelsExcludeAny)
require.Len(t, gp.LabelsExcludeAll, 2)
require.ElementsMatch(t, []string{"Label B", "Label C"}, []string{gp.LabelsExcludeAll[0].LabelName, gp.LabelsExcludeAll[1].LabelName})
// The team policy persisted both its include_all and exclude_any scopes.
tm, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
teamPolicies, _, err := s.DS.ListTeamPolicies(ctx, tm.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, teamPolicies, 1)
tp := teamPolicies[0]
require.Equal(t, "Team Combined Scope", tp.Name)
require.Empty(t, tp.LabelsIncludeAny)
require.Len(t, tp.LabelsIncludeAll, 2)
require.ElementsMatch(t, []string{"Label A", "Label B"}, []string{tp.LabelsIncludeAll[0].LabelName, tp.LabelsIncludeAll[1].LabelName})
require.Len(t, tp.LabelsExcludeAny, 1)
require.Equal(t, "Label C", tp.LabelsExcludeAny[0].LabelName)
require.Empty(t, tp.LabelsExcludeAll)
// A policy that specifies two include scopes is rejected.
conflictConfig := `
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: label_scopes_secret
agent_options:
controls:
reports:
labels:
- name: Label A
label_membership_type: dynamic
query: SELECT 1
- name: Label B
label_membership_type: dynamic
query: SELECT 1
policies:
- name: Conflicting Scope
query: SELECT 1;
labels_include_any:
- Label A
labels_include_all:
- Label B
`
conflictFile := filepath.Join(tempDir, "conflict.yml")
require.NoError(t, os.WriteFile(conflictFile, []byte(conflictConfig), 0o644)) //nolint:gosec
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", conflictFile, "--dry-run"})
require.ErrorContains(t, err, "at most one of labels_include_any or labels_include_all")
}
// 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() {
+1 -1
View File
@@ -165,7 +165,7 @@ func updatePolicyLabelsTx(ctx context.Context, tx sqlx.ExtContext, policy *fleet
WHERE name IN (?)
`
if err := policy.Verify(); err != nil {
if err := policy.VerifyLabelScopes(); err != nil {
return ctxerr.Wrap(ctx, err, "validating policy label scopes")
}
+4 -6
View File
@@ -430,12 +430,10 @@ type PolicyData struct {
UpdateCreateTimestamps
}
// Verify checks that the policy's label scopes are valid: at most one include
// scope (any/all) combined with at most one exclude scope (any/all), with no
// label appearing in both an include and an exclude list. It validates only the
// label scopes — name/query/platform are validated on the payload types at
// create/modify time.
func (p PolicyData) Verify() error {
// VerifyLabelScopes checks that the policy's label scopes are valid: at most one
// include scope (any/all) combined with at most one exclude scope (any/all),
// with no label appearing in both an include and an exclude list.
func (p PolicyData) VerifyLabelScopes() error {
return verifyPolicyLabelScopes(
LabelIdentsToNames(p.LabelsIncludeAny),
LabelIdentsToNames(p.LabelsIncludeAll),