Files
fleet/openspec/changes/chart-disabling-gitops-api/proposal.md
T
Scott Gress c3b82539a5 Allow disabling historical data collection (GitOps / API) (#44488)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** For #44077

# Details

* Adds `historical_data` key to app and team config (and gitops) with
`uptime` and `vulnerabilities` subkeys. Keys default to `true`, meaning
"collect this data"
* Adds `enabled_historical_dataset` and `disabled_historical_dataset`
activities when these values are flipped via GitOps or the config APIs

The majority of the file changes in here are GitOps test files that need
to be updated to have the new config in them.

**This PR does _not_ implement using these configs to actually disable
data collection or purge data; that will come in a follow-up PR (as well
as the front-end)**

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] 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.
n/a, unreleased

## Testing

- [X] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually
  #### Defaults
- [X] Fresh install: `GET /api/v1/fleet/config` returns
`features.historical_data.uptime: true` and
`features.historical_data.vulnerabilities: true`
- [X] Created a new fleet via `POST /api/v1/fleet/teams`, then `GET
/api/v1/fleet/fleets/{id}` returns `features.historical_data.uptime:
true` and `features.historical_data.vulnerabilities: true`

  #### Global PATCH (`POST /api/v1/fleet/config`)
- [X] PATCHed `{"features": {"historical_data": {"vulnerabilities":
false}}}` — `vulnerabilities` flipped to `false`, `uptime` unchanged at
`true`
- [X] PATCHed `{"features": {"historical_data": {"uptime": false,
"vulnerabilities": true}}}` — both values applied as sent
- [X] PATCHed `{"features": {"historical_data": {"vulnerabilites":
false}}}` (typo in sub-key) — request rejected with 4xx, stored config
unchanged

  #### Fleet PATCH (`PATCH /api/v1/fleet/fleets/{id}`)
- [X] PATCHed a fleet with `{"features": {"historical_data": {"uptime":
false}}}` — fleet's `uptime` flipped to `false`, `vulnerabilities`
unchanged
- [X] Subsequent `GET /api/v1/fleet/fleets/{id}` returns the toggled
values under `features.historical_data` (storage shape is symmetric with
global)
- [X] PATCHed a fleet with `{"features": {"enable_host_users": false}}`
(a non-`historical_data` features sub-field) — request returned 200 but
the fleet's `enable_host_users` is unchanged (silently ignored, per
existing endpoint convention)

  #### GitOps — global (`fleetctl gitops -f global.yml`)
- [X] Applied a YAML with `features.historical_data: {uptime: true,
vulnerabilities: false}` — `vulnerabilities` is `false` after apply,
`uptime` is `true`
- [X] Applied a YAML whose `org_settings` omits `features` entirely —
both sub-keys are `true` after apply (defaults injected even if
previously disabled)
- [X] Applied a YAML where `historical_data` only contains `uptime:
false` — `uptime: false` is honored, `vulnerabilities` defaults to
`true`
- [X] Disabled `vulnerabilities` via the API, then ran `fleetctl gitops`
with a YAML that doesn't pin it — `vulnerabilities` flips back to `true`
(this is intentional; gitops is the source of truth)

  #### GitOps — fleet
- [X] Applied a fleet YAML with `features.historical_data: {uptime:
false}` — that fleet has `uptime: false`, `vulnerabilities: true` after
apply
- [X] Applied a fleet YAML whose `team_settings.features` omits
`historical_data` — both sub-keys are `true` after apply
- [X] Applied a fleet YAML that omits `features` entirely — both
sub-keys are `true` after apply

  #### `fleetctl apply` (legacy, partial-merge)
- [ ] Disabled `vulnerabilities` via the API, then ran `fleetctl apply`
with a YAML that doesn't mention `historical_data` — `vulnerabilities`
is still `false` (apply leaves omitted fields alone)

  #### Activities — global
- [X] After PATCHing global to disable `vulnerabilities`, the latest
activity is `disabled_historical_dataset` with payload `{"dataset":
"vulnerabilities", "fleet_id": null, "fleet_name": null}`
- [X] After PATCHing global with both sub-keys flipping in one request,
two activities are emitted (one per sub-key)
- [X] After PATCHing global with the same values that are already
stored, zero new activities are emitted
- [X] After re-enabling a previously disabled dataset, the activity type
is `enabled_historical_dataset`

  #### Activities — per fleet
- [X] After PATCHing fleet `workstations` to disable `uptime`, the
activity is `disabled_historical_dataset` with payload `{"dataset":
"uptime", "fleet_id": <workstations id>, "fleet_name": "workstations"}`
- [X] Toggling the same dataset on two different fleets produces two
distinct activities, one per fleet
- [X] After a fleet PATCH with the same values already stored, zero new
activities are emitted

For unreleased bug fixes in a release candidate, one of:

- [X] Confirmed that the fix is not expected to adversely impact load
test results
- [ ] Alerted the release DRI if additional load testing is needed

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [X] Verified that the setting is exported via `fleetctl
generate-gitops`
- [X] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
  - https://github.com/fleetdm/fleet/pull/44703
- [X] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [X] Verified that any relevant UI is disabled when GitOps mode is
enabled


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

* **New Features**
* Historical-data controls: per-org and per-team toggles for uptime and
vulnerability time‑series, with defaults applied when keys are omitted
and enable/disable activities emitted on changes.

* **Bug Fixes**
* Partial updates and PATCH/GitOps flows preserve unspecified
historical-data sub-keys instead of clearing them.

* **Tests**
* Expanded unit and integration tests covering defaults, partial
PATCH/GitOps behavior, idempotency, and activity emission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-05 11:12:23 -05:00

9.0 KiB

Why

Issue #44077 ("Allow disabling data collection for charts") adds admin-visible switches to disable per-dataset historical-data collection for the dashboard charts. The frontend, cron gating, and data-drop work are tracked separately; this change delivers the GitOps / API surface those consumers depend on:

  • A new features.historical_data config object with a sub-key per dataset.
  • Both POST /api/v1/fleet/config and PATCH /api/v1/fleet/teams/{id} accept the new keys with standard PATCH-merge semantics.
  • GitOps (global YAML and fleet YAML) accepts the new keys.
  • A pair of audit activities fire when a dataset is enabled or disabled.
  • A small mapping helper makes the "config key vs internal dataset name" translation explicit and centralized — the issue mandates the config keys uptime and vulnerabilities, but the internal dataset names are uptime and cve. The mismatch needs a single, greppable mapping point so callers don't hardcode the translation.

Default for both sub-keys is true. Existing deployments upgrade with both datasets enabled, preserving current behavior.

What Changes

Data shape

  • Add HistoricalDataSettings struct to server/fleet/app.go:
    type HistoricalDataSettings struct {
        Uptime          bool `json:"uptime"`           // dataset "uptime"
        Vulnerabilities bool `json:"vulnerabilities"`  // dataset "cve"
    }
    
    Both fields without omitempty so an explicit false round-trips through GitOps overwrite mode (matches EnableHostUsers / EnableSoftwareInventory).
  • Add HistoricalData HistoricalDataSettings to Features with JSON tag historical_data (no omitempty).
  • Update the existing "WARNING: account in the Features Clone implementation" comment block. HistoricalDataSettings is value-type-only so clone := *f already deep-copies it; verify in unit test.
  • Because Team.Config embeds Features, historical_data is automatically available per-fleet with no schema change.

Mapping helper

  • Add a method on HistoricalDataSettings:
    func (h HistoricalDataSettings) Enabled(dataset string) (bool, error) {
        switch dataset {
        case "uptime": return h.Uptime, nil
        case "cve":    return h.Vulnerabilities, nil
        default:       return false, fmt.Errorf("unknown dataset %q", dataset)
        }
    }
    
    The cron consumer (separate change) will use this. The mapping lives once, on the type that owns the data, with a safelist switch that prevents string interpolation into JSON paths and provides a clear error for unknown datasets.

Defaults

  • Features.ApplyDefaults() sets both sub-fields true. ApplyDefaultsForNewInstalls() already delegates to ApplyDefaults(); no separate change needed.
  • The existing pre-unmarshal ApplyDefaults priming on both read paths (server/datastore/mysql/app_configs.go and server/datastore/mysql/teams.go) means existing rows whose stored JSON omits historical_data read back with both sub-fields true.
  • A small backfill is added to migration 20260423161823_AddHostSCDData to set features.historical_data.{uptime,vulnerabilities} to true on every app_config_json row and every teams.config row. This is required because earlier migrations using updateAppConfigJSON (and the inline TeamConfig round-trip pattern) re-marshal the whole struct on save, and would otherwise stamp the new field's zero value (false) into stored JSON the moment it appeared in Go — silently degrading the upgrade default to false. The backfill runs in the same migration that creates host_scd_data (the chart data table) so it's conceptually grouped with the rest of the chart-disabling work, and this code lands in 4.85.0 before any deployment exposes the toggle to admins.

API surface

  • POST /api/v1/fleet/config (ModifyAppConfig) accepts the new keys. PATCH-merge works for free: the endpoint unmarshals raw JSON into the existing config, and Go's JSON decoder recurses into nested structs, only touching fields present in the payload.
  • PATCH /api/v1/fleet/fleets/{id} (ModifyTeam) accepts the same {features: {historical_data: {...}}} shape, but the wiring is different because ModifyTeam takes a parsed TeamPayload rather than raw bytes. Add a TeamPayloadFeatures payload-only subset of Features containing just HistoricalData *HistoricalDataPayload, and a HistoricalDataPayload whose sub-fields are optjson.Bool (matching the optjson.Bool-based partial-PATCH pattern already used by mdm.enable_disk_encryption on this endpoint). Sub-keys whose Valid is false are left untouched; sub-keys with explicit values flip the stored fleet config. Other features sub-fields (enable_host_users, enable_software_inventory, additional_queries, detail_query_overrides) remain writable per-fleet only via /spec/fleets. Storage and read-back shapes are identical to global (features.historical_data); only the request decoding is structurally different.
  • GitOps (global config YAML and fleet YAML) accepts the keys via the same Features unmarshal that already handles other Features fields. ApplySpecOptions.Overwrite=true is the same risk profile as every other Features field today; documented in the GitOps YAML reference.

Activities

  • Add two activity types:
    type ActivityTypeEnabledHistoricalDataset struct {
        Dataset   string  `json:"dataset"`     // config key: "uptime" or "vulnerabilities"
        FleetID   *uint   `json:"fleet_id"`    // nil for global
        FleetName *string `json:"fleet_name"`  // nil for global
    }
    
    type ActivityTypeDisabledHistoricalDataset struct {
        Dataset   string  `json:"dataset"`
        FleetID   *uint   `json:"fleet_id"`
        FleetName *string `json:"fleet_name"`
    }
    
    Activity-type strings: enabled_historical_dataset / disabled_historical_dataset.
  • One activity per sub-field that flipped per request. No-op PATCH (same values back) emits zero activities. Global emits with fleet_id / fleet_name nil; per-fleet emits with both populated.
  • The dataset payload uses the config key (vulnerabilities, not cve) since the audit log is admin-facing and admins see config keys in YAML and (eventually) the UI.

Docs

  • docs/REST API/rest-api.md — global config and fleet-modify sections gain a features.historical_data shape and example payload.
  • docs/Configuration/yaml-files.md — global and fleet sections document the keys, defaults, and the GitOps overwrite-on-omit behavior.
  • docs/Contributing/reference/audit-logs.md — entries for both new activity types with payload field documentation and global + fleet-scoped examples.

Capabilities

Added Capabilities

  • chart-historical-data-settings — describes the config shape, defaults, PATCH-merge semantics, GitOps overwrite behavior, dataset-name ↔ config-key mapping, fleet-scoped settings inheritance, and audit activity emission for features.historical_data.

Impact

  • One backfill migration. Features is stored as a JSON blob in app_config_json and teams.config. Migration 20260423161823 (which also creates host_scd_data) writes features.historical_data.{uptime,vulnerabilities} = true on every existing row. The ApplyDefaults-before-unmarshal pattern already in the storage read paths covers fresh installs and any row whose stored JSON simply omits the key.
  • No API version bump. New keys in an existing JSON body are backwards-compatible. EnableStrictDecoding rejects unknown fields; historical_data is known after this change.
  • Consumer changes deferred. The cron gating that uses the helper, the data-drop on disable, the dashboard "data collection disabled" empty state, and the Advanced / Fleet Settings UI checkboxes are all separate changes. This change ships the API contract those consumers depend on.

Out of Scope

  • Cron gating in server/chart/. A separate change wires the Enabled(dataset) helper into Service.CollectDatasets and adds a per-fleet filter on FindRecentlySeenHostIDs. This change ships the helper but does not call it.
  • Data drop on disable. The issue requires that disabling a dataset globally truncates its host_scd_data rows, and disabling per-fleet scrubs that fleet's contributions. This is a separate change.
  • Frontend UI. "Disable hosts active" / "Disable vulnerabilities" checkboxes on Advanced + Fleet Settings, the confirmation dialog, and the dashboard empty-state messaging are all separate changes.
  • Per-dataset defaults other than true. A future expensive/opt-in dataset would need its own design conversation.
  • Tri-state "inherit from global" on the fleet value. Plain bool with true/true defaults; the global AND fleet rule is the only precedence mechanic. Cron-side enforcement is the consumer's concern.
  • DeviceFeatures surfacing. The device-endpoint subset of app config does not expose historical_data. This setting controls server-side rollup collection; nothing about device behavior changes.