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:
@@ -0,0 +1 @@
|
||||
* Added `gitOpsModeEnabled` and `gitOpsModeExceptions` to the anonymous statistics payload.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user