fleet/server/datastore/mysql/statistics.go
Lucas Manuel Rodriguez bd18bac797
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 -->
2026-04-27 08:28:49 -03:00

371 lines
14 KiB
Go

package mysql
import (
"context"
"database/sql"
"time"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/jmoiron/sqlx"
)
type statistics struct {
fleet.UpdateCreateTimestamps
Identifier string `db:"anonymous_identifier"`
}
func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) {
lic, _ := license.FromContext(ctx)
computeStats := func(stats *fleet.StatisticsPayload, since time.Time) error {
enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount enrolled hosts by os")
}
numHostsABMPending, err := numHostsABMPendingDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount hosts that are ABM pending")
}
amountUsers, err := tableRowsCount(ctx, ds.reader(ctx), "users")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount users")
}
amountSoftwaresVersions, err := tableRowsCount(ctx, ds.reader(ctx), "software")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount software")
}
amountHostSoftwares, err := tableRowsCount(ctx, ds.reader(ctx), "host_software")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount host_software")
}
amountSoftwareTitles, err := tableRowsCount(ctx, ds.reader(ctx), "software_titles")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount software_titles")
}
amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.reader(ctx), "host_software_installed_paths")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount host_software_installed_paths")
}
amountSoftwareCpes, err := tableRowsCount(ctx, ds.reader(ctx), "software_cpe")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount software_cpe")
}
amountSoftwareCves, err := tableRowsCount(ctx, ds.reader(ctx), "software_cve")
if err != nil {
return ctxerr.Wrap(ctx, err, "amount software_cve")
}
amountTeams, err := amountTeamsDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount teams")
}
amountPolicies, err := amountPoliciesDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount policies")
}
amountLabels, err := amountLabelsDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount labels")
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "statistics app config")
}
amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.reader(ctx), since)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount active users")
}
amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.reader(ctx))
if err != nil && err != sql.ErrNoRows {
return ctxerr.Wrap(ctx, err, "amount policy violation days")
}
storedErrs, err := ctxerr.Aggregate(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "statistics error store")
}
amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.reader(ctx), ds.logger, config)
if err != nil {
return ctxerr.Wrap(ctx, err, "amount hosts not responding")
}
amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount hosts by orbit version")
}
amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "amount hosts by osquery version")
}
numHostsFleetDesktopEnabled, err := numHostsFleetDesktopEnabledDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "number of hosts with Fleet desktop installed")
}
numQueries, err := numSavedQueriesDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "number of saved queries in DB")
}
fleetMaintainedAppsMacOS, fleetMaintainedAppsWindows, err := fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "fleet maintained apps")
}
stats.NumHostsEnrolled = amountEnrolledHosts
stats.NumHostsABMPending = numHostsABMPending
stats.NumUsers = amountUsers
stats.NumSoftwareVersions = amountSoftwaresVersions
stats.NumHostSoftwares = amountHostSoftwares
stats.NumSoftwareTitles = amountSoftwareTitles
stats.NumHostSoftwareInstalledPaths = amountHostSoftwareInstalledPaths
stats.NumSoftwareCPEs = amountSoftwareCpes
stats.NumSoftwareCVEs = amountSoftwareCves
stats.NumTeams = amountTeams
stats.NumPolicies = amountPolicies
stats.NumLabels = amountLabels
stats.SoftwareInventoryEnabled = appConfig.Features.EnableSoftwareInventory
stats.VulnDetectionEnabled = config.Vulnerabilities.DatabasesPath != "" || appConfig.VulnerabilitySettings.DatabasesPath != ""
stats.SystemUsersEnabled = appConfig.Features.EnableHostUsers
stats.HostsStatusWebHookEnabled = appConfig.WebhookSettings.HostStatusWebhook.Enable
stats.MDMMacOsEnabled = appConfig.MDM.EnabledAndConfigured
stats.HostExpiryEnabled = appConfig.HostExpirySettings.HostExpiryEnabled
stats.MDMWindowsEnabled = appConfig.MDM.WindowsEnabledAndConfigured
stats.MDMRecoveryLockPasswordEnabled = appConfig.MDM.EnableRecoveryLockPassword.Value
stats.LiveQueryDisabled = appConfig.ServerSettings.LiveQueryDisabled
stats.NumWeeklyActiveUsers = amountWeeklyUsers
stats.NumWeeklyPolicyViolationDaysActual = amountPolicyViolationDaysActual
stats.NumWeeklyPolicyViolationDaysPossible = amountPolicyViolationDaysPossible
stats.HostsEnrolledByOperatingSystem = enrolledHostsByOS
stats.HostsEnrolledByOrbitVersion = amountHostsByOrbitVersion
stats.HostsEnrolledByOsqueryVersion = amountHostsByOsqueryVersion
stats.StoredErrors = storedErrs
stats.NumHostsNotResponding = amountHostsNotResponding
stats.Organization = "unknown"
if lic != nil && lic.IsPremium() {
stats.Organization = lic.GetOrganization()
}
stats.AIFeaturesDisabled = appConfig.ServerSettings.AIFeaturesDisabled
stats.MaintenanceWindowsConfigured = len(appConfig.Integrations.GoogleCalendar) > 0 && appConfig.Integrations.GoogleCalendar[0].Domain != "" && !appConfig.Integrations.GoogleCalendar[0].ApiKey.IsEmpty()
stats.MaintenanceWindowsEnabled = false
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
}}, fleet.ListOptions{})
if err != nil {
return ctxerr.Wrap(ctx, err, "list teams")
}
for _, team := range teams {
if team.Config.Integrations.GoogleCalendar != nil && team.Config.Integrations.GoogleCalendar.Enable {
stats.MaintenanceWindowsEnabled = true
break
}
}
stats.NumHostsFleetDesktopEnabled = numHostsFleetDesktopEnabled
stats.NumQueries = numQueries
stats.FleetMaintainedAppsMacOS = fleetMaintainedAppsMacOS
stats.FleetMaintainedAppsWindows = fleetMaintainedAppsWindows
stats.ConditionalAccessEnabled, err = ds.conditionalAccessEnabledOnATeam(ctx, teams)
if err != nil {
return ctxerr.Wrap(ctx, err, "conditional access enabled on a team")
}
if appConfig.ConditionalAccess != nil {
stats.OktaConditionalAccessConfigured = appConfig.ConditionalAccess.OktaConfigured()
stats.ConditionalAccessBypassDisabled = !appConfig.ConditionalAccess.BypassEnabled()
}
stats.EntraConditionalAccessConfigured, err = ds.entraConditionalAccessConfigured(ctx, config)
if err != nil {
return ctxerr.Wrap(ctx, err, "entra conditional access configured")
}
stats.GitOpsModeEnabled = appConfig.GitOpsConfig.GitopsModeEnabled
stats.GitOpsModeExceptions = gitOpsExceptionsList(appConfig.GitOpsConfig.Exceptions)
return nil
}
dest := statistics{}
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`)
if err != nil {
if err == sql.ErrNoRows {
anonIdentifier, err := server.GenerateRandomText(64)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "generate random text")
}
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO statistics(anonymous_identifier) VALUES (?)`, anonIdentifier)
if err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "insert statistics")
}
// compute active weekly users since now - frequency
stats := fleet.StatisticsPayload{
AnonymousIdentifier: anonIdentifier,
FleetVersion: version.Version().Version,
LicenseTier: fleet.TierFree,
}
if lic != nil {
stats.LicenseTier = lic.GetTier()
}
if err := computeStats(&stats, time.Now().Add(-frequency)); err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
}
return stats, true, nil
}
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "get statistics")
}
lastUpdated := dest.UpdatedAt
if dest.CreatedAt.After(dest.UpdatedAt) {
lastUpdated = dest.CreatedAt
}
if time.Now().Before(lastUpdated.Add(frequency)) {
return fleet.StatisticsPayload{}, false, nil
}
stats := fleet.StatisticsPayload{
AnonymousIdentifier: dest.Identifier,
FleetVersion: version.Version().Version,
LicenseTier: fleet.TierFree,
}
if lic != nil {
stats.LicenseTier = lic.GetTier()
}
if err := computeStats(&stats, lastUpdated); err != nil {
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
}
return stats, true, nil
}
func (ds *Datastore) RecordStatisticsSent(ctx context.Context) error {
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE statistics SET updated_at = CURRENT_TIMESTAMP LIMIT 1`)
return ctxerr.Wrap(ctx, err, "update statistics")
}
func (ds *Datastore) CleanupStatistics(ctx context.Context) error {
// reset weekly count of policy violation days
if err := ds.InitializePolicyViolationDays(ctx); err != nil {
return err
}
return nil
}
func (ds *Datastore) GetTableRowCounts(ctx context.Context) (map[string]uint, error) {
return ds.getTableRowCountsViaInformationSchema(ctx)
}
func (ds *Datastore) getTableRowCountsViaInformationSchema(ctx context.Context) (map[string]uint, error) {
var results []struct {
Table string `db:"TABLE_NAME"`
Rows uint `db:"table_rows"`
}
if err := sqlx.SelectContext(
ctx,
ds.reader(ctx),
&results,
"SELECT table_name, COALESCE(table_rows, 0) table_rows FROM information_schema.tables WHERE table_schema = (SELECT DATABASE())",
); err != nil {
return nil, err
}
byName := make(map[string]uint)
for _, row := range results {
byName[row.Table] = row.Rows
}
return byName, nil
}
// fleetMaintainedAppsInUseDB returns arrays of Fleet-maintained app slugs grouped by platform (darwin for macOS, windows for Windows)
func fleetMaintainedAppsInUseDB(ctx context.Context, db sqlx.QueryerContext) (macOSApps []string, windowsApps []string, err error) {
const query = `
SELECT DISTINCT fma.slug, fma.platform
FROM software_installers si
INNER JOIN fleet_maintained_apps fma ON si.fleet_maintained_app_id = fma.id
WHERE si.fleet_maintained_app_id IS NOT NULL AND fma.platform IN ('darwin', 'windows')
ORDER BY fma.platform, fma.slug
`
type appResult struct {
Slug string `db:"slug"`
Platform string `db:"platform"`
}
var results []appResult
if err := sqlx.SelectContext(ctx, db, &results, query); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "selecting fleet maintained apps in use")
}
// Initialize as empty slices (not nil) so they serialize as [] instead of null
macOSApps = make([]string, 0)
windowsApps = make([]string, 0)
for _, app := range results {
if app.Platform == "darwin" {
macOSApps = append(macOSApps, app.Slug)
} else if app.Platform == "windows" {
windowsApps = append(windowsApps, app.Slug)
}
}
return macOSApps, windowsApps, nil
}
func (ds *Datastore) entraConditionalAccessConfigured(ctx context.Context, fleetConfig config.FleetConfig) (bool, error) {
// Check if the needed server configuration for Conditional Access is set.
if !fleetConfig.MicrosoftCompliancePartner.IsSet() {
return false, nil
}
// Check if the integration is fully configured.
integration, err := ds.ConditionalAccessMicrosoftGet(ctx)
if err != nil {
if fleet.IsNotFound(err) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "failed to load the integration")
}
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)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "failed to load appconfig")
}
if cfg.Integrations.ConditionalAccessEnabled.Set && cfg.Integrations.ConditionalAccessEnabled.Value {
return true, nil
}
// Check for the setting in teams.
for _, team := range teams {
if team.Config.Integrations.ConditionalAccessEnabled.Set && team.Config.Integrations.ConditionalAccessEnabled.Value {
return true, nil
}
}
return false, nil
}