diff --git a/changes/42240-add-gitops-stats b/changes/42240-add-gitops-stats new file mode 100644 index 0000000000..398a7f6c76 --- /dev/null +++ b/changes/42240-add-gitops-stats @@ -0,0 +1 @@ +* Added `gitOpsModeEnabled` and `gitOpsModeExceptions` to the anonymous statistics payload. diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 7f9dc058dd..a150f76756 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -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) { diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 4562993175..3850b81647 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -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) diff --git a/server/datastore/mysql/statistics_test.go b/server/datastore/mysql/statistics_test.go index c868a4c97b..262e4bda1f 100644 --- a/server/datastore/mysql/statistics_test.go +++ b/server/datastore/mysql/statistics_test.go @@ -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) +} diff --git a/server/fleet/statistics.go b/server/fleet/statistics.go index 6289e4e91e..e03efdb0f0 100644 --- a/server/fleet/statistics.go +++ b/server/fleet/statistics.go @@ -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 {