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

190 lines
9.0 KiB
Markdown

## 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`:
```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`:
```go
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:
```go
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.