Adding gitOpsModeEnabled and gitOpsModeExceptions to anonymous statistics payload (#44161)

**Related issue:** Resolves #42240.

- [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
- [X] QA'd all new/changed functionality manually

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

* **New Features**
* Statistics now include GitOps mode: whether it’s enabled and the
ordered list of configured exception categories (serializes as an empty
list when none).

* **Tests**
* Added tests for GitOps-related statistics transitions and made
statistics-timing tests deterministic for reliable behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lucas Manuel Rodriguez
2026-04-27 08:28:49 -03:00
committed by GitHub
parent cd1909e26d
commit bd18bac797
5 changed files with 126 additions and 31 deletions
+1
View File
@@ -0,0 +1 @@
* Added `gitOpsModeEnabled` and `gitOpsModeExceptions` to the anonymous statistics payload.
+3 -1
View File
@@ -125,6 +125,8 @@ func TestMaybeSendStatistics(t *testing.T) {
NumHostsFleetDesktopEnabled: 1984,
FleetMaintainedAppsMacOS: []string{"1password/darwin"},
FleetMaintainedAppsWindows: []string{"google-chrome/windows"},
GitOpsModeEnabled: true,
GitOpsModeExceptions: []string{"labels", "software", "secrets"},
}, true, nil
}
recorded := false
@@ -143,7 +145,7 @@ func TestMaybeSendStatistics(t *testing.T) {
require.NoError(t, err)
assert.True(t, recorded)
require.True(t, cleanedup)
assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false}`, requestBody)
assert.JSONEq(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false,"gitOpsModeEnabled":true,"gitOpsModeExceptions":["labels","software","secrets"]}`, requestBody)
}
func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) {
+20 -3
View File
@@ -83,9 +83,7 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
return ctxerr.Wrap(ctx, err, "amount active users")
}
amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.reader(ctx))
if err == sql.ErrNoRows {
ds.logger.DebugContext(ctx, "amount policy violation days", "err", err)
} else if err != nil {
if err != nil && err != sql.ErrNoRows {
return ctxerr.Wrap(ctx, err, "amount policy violation days")
}
storedErrs, err := ctxerr.Aggregate(ctx)
@@ -186,6 +184,9 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
return ctxerr.Wrap(ctx, err, "entra conditional access configured")
}
stats.GitOpsModeEnabled = appConfig.GitOpsConfig.GitopsModeEnabled
stats.GitOpsModeExceptions = gitOpsExceptionsList(appConfig.GitOpsConfig.Exceptions)
return nil
}
@@ -334,6 +335,22 @@ func (ds *Datastore) entraConditionalAccessConfigured(ctx context.Context, fleet
return integration.SetupDone, nil
}
// gitOpsExceptionsList returns the names of enabled GitOps mode exceptions, in a stable order.
// Always returns a non-nil slice so the payload serializes as [] when empty.
func gitOpsExceptionsList(e fleet.GitOpsExceptions) []string {
exceptions := make([]string, 0, 3)
if e.Labels {
exceptions = append(exceptions, "labels")
}
if e.Software {
exceptions = append(exceptions, "software")
}
if e.Secrets {
exceptions = append(exceptions, "secrets")
}
return exceptions
}
func (ds *Datastore) conditionalAccessEnabledOnATeam(ctx context.Context, teams []*fleet.Team) (bool, error) {
// Check configuration for "Unassigned" is stored in the main appconfig.
cfg, err := ds.AppConfig(ctx)
+96 -27
View File
@@ -28,6 +28,7 @@ func TestStatistics(t *testing.T) {
{"ShouldSend", testStatisticsShouldSend},
{"ConditionalAccessStatistics", testConditionalAccessStatistics},
{"FleetMaintainedAppsInUse", testFleetMaintainedAppsInUse},
{"GitOpsModeStatistics", testGitOpsModeStatistics},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -101,6 +102,10 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.False(t, stats.ConditionalAccessBypassDisabled)
assert.False(t, stats.ConditionalAccessEnabled)
assert.False(t, stats.EntraConditionalAccessConfigured)
assert.False(t, stats.GitOpsModeEnabled)
// Existing-install defaults applied by migration 20260323144117_AddGitOpsExceptionsToAppConfig
// (labels + secrets on, software off) and baked into the dumped test schema.
assert.Equal(t, []string{"labels", "secrets"}, stats.GitOpsModeExceptions)
firstIdentifier := stats.AnonymousIdentifier
@@ -483,6 +488,12 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.False(t, stats.ConditionalAccessBypassDisabled)
}
func markStatisticsStale(t *testing.T, ctx context.Context, ds *Datastore) {
_, err := ds.writer(ctx).ExecContext(ctx,
`UPDATE statistics SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR), updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) LIMIT 1`)
require.NoError(t, err)
}
func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
eh := ctxerr.MockHandler{}
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) {
@@ -501,9 +512,7 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.False(t, stats.ConditionalAccessEnabled)
assert.False(t, stats.EntraConditionalAccessConfigured)
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Enable conditional access on appconfig (for "No team")
cfg, err := ds.AppConfig(ctx)
@@ -518,11 +527,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, stats.ConditionalAccessEnabled)
assert.False(t, stats.EntraConditionalAccessConfigured)
// Disable on appconfig
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Disable on appconfig
cfg.Integrations.ConditionalAccessEnabled = optjson.SetBool(false)
err = ds.SaveAppConfig(ctx, cfg)
require.NoError(t, err)
@@ -532,11 +539,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.False(t, stats.ConditionalAccessEnabled)
// Enable conditional access on a team
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Enable conditional access on a team
team, err := ds.NewTeam(ctx, &fleet.Team{
Name: "ca-team",
Description: "team with conditional access",
@@ -551,11 +556,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.True(t, stats.ConditionalAccessEnabled)
// Disable on team
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Disable on team
team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(false)
_, err = ds.SaveTeam(ctx, team)
require.NoError(t, err)
@@ -565,11 +568,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.False(t, stats.ConditionalAccessEnabled)
// Test Entra conditional access: create the integration but without setup done
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Test Entra conditional access: create the integration but without setup done
fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{
ProxyAPIKey: "test-key",
}
@@ -581,11 +582,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.False(t, stats.EntraConditionalAccessConfigured) // setup not done yet
// Mark setup done
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Mark setup done
err = ds.ConditionalAccessMicrosoftMarkSetupDone(ctx)
require.NoError(t, err)
@@ -594,11 +593,9 @@ func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
assert.True(t, shouldSend)
assert.True(t, stats.EntraConditionalAccessConfigured)
// Without the fleet config proxy key, should be false even with setup done
err = ds.RecordStatisticsSent(ctx)
require.NoError(t, err)
time.Sleep(1100 * time.Millisecond)
markStatisticsStale(t, ctx, ds)
// Without the fleet config proxy key, should be false even with setup done
fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{}
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
@@ -772,3 +769,75 @@ func testFleetMaintainedAppsInUse(t *testing.T, ds *Datastore) {
assert.Equal(t, []string{"slack/darwin", "zoom/darwin"}, macOSApps)
assert.Equal(t, []string{"microsoft-teams/windows", "zoom/windows"}, windowsApps)
}
func testGitOpsModeStatistics(t *testing.T, ds *Datastore) {
eh := ctxerr.MockHandler{}
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) {
return nil, nil
}
ctx := ctxerr.NewContext(context.Background(), eh)
premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"}
fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}}
// Create a new app config so ApplyDefaults runs (new-install defaults: only "secrets" exception).
_, err := ds.NewAppConfig(ctx, &fleet.AppConfig{
OrgInfo: fleet.OrgInfo{OrgName: "Test", OrgLogoURL: "localhost:8080/logo.png"},
})
require.NoError(t, err)
// Default state (new install): GitOps mode disabled, only "secrets" is a default exception.
stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.False(t, stats.GitOpsModeEnabled)
assert.Equal(t, []string{"secrets"}, stats.GitOpsModeExceptions)
markStatisticsStale(t, ctx, ds)
// Enable GitOps mode and add labels + software exceptions.
cfg, err := ds.AppConfig(ctx)
require.NoError(t, err)
cfg.GitOpsConfig.GitopsModeEnabled = true
cfg.GitOpsConfig.RepositoryURL = "https://github.com/example/fleet-config"
cfg.GitOpsConfig.Exceptions.Labels = true
cfg.GitOpsConfig.Exceptions.Software = true
cfg.GitOpsConfig.Exceptions.Secrets = true
require.NoError(t, ds.SaveAppConfig(ctx, cfg))
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.True(t, stats.GitOpsModeEnabled)
assert.Equal(t, []string{"labels", "software", "secrets"}, stats.GitOpsModeExceptions)
markStatisticsStale(t, ctx, ds)
// Disable GitOps mode but keep exceptions configured — exceptions are persisted independently.
cfg, err = ds.AppConfig(ctx)
require.NoError(t, err)
cfg.GitOpsConfig.GitopsModeEnabled = false
require.NoError(t, ds.SaveAppConfig(ctx, cfg))
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.False(t, stats.GitOpsModeEnabled)
assert.Equal(t, []string{"labels", "software", "secrets"}, stats.GitOpsModeExceptions)
markStatisticsStale(t, ctx, ds)
// Clear all exceptions: should serialize as empty slice, not nil.
cfg, err = ds.AppConfig(ctx)
require.NoError(t, err)
cfg.GitOpsConfig.Exceptions.Labels = false
cfg.GitOpsConfig.Exceptions.Software = false
cfg.GitOpsConfig.Exceptions.Secrets = false
require.NoError(t, ds.SaveAppConfig(ctx, cfg))
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
require.NoError(t, err)
assert.True(t, shouldSend)
assert.False(t, stats.GitOpsModeEnabled)
assert.Equal(t, []string{}, stats.GitOpsModeExceptions)
}
+6
View File
@@ -73,6 +73,12 @@ type StatisticsPayload struct {
ConditionalAccessBypassDisabled bool `json:"conditionalAccessBypassDisabled"`
// EntraConditionalAccessConfigured indicates if the Entra conditional access integration is configured.
EntraConditionalAccessConfigured bool `json:"entraConditionalAccessConfigured"`
// GitOpsModeEnabled indicates whether GitOps mode is enabled in the app config.
GitOpsModeEnabled bool `json:"gitOpsModeEnabled"`
// GitOpsModeExceptions lists the configured GitOps mode exceptions (e.g. "labels", "software", "secrets").
// Exceptions are persisted independently of GitOpsModeEnabled.
GitOpsModeExceptions []string `json:"gitOpsModeExceptions"`
}
type HostsCountByOrbitVersion struct {