<!-- 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 -->
16 KiB
Context
Issue #44077 ships per-dataset "disable data collection" switches for the dashboard charts. The work decomposes naturally:
- Config surface (this change) — Go struct, defaults, PATCH/GitOps plumbing, audit activities, mapping helper.
- Cron gating — orchestrator skips disabled datasets, passes
enabled-fleet sets into
Dataset.Collect. - Data drop on disable — truncate per-dataset rows / scrub per-fleet rows when a flag flips.
- Frontend — Advanced card, Fleet Settings section, confirmation dialog, dashboard empty state.
This change is the foundation; the others are separate. The natural home
for these flags is AppConfig.Features because Team.Config already embeds
Features, giving us per-fleet overrides "for free."
Decisions
1. Nested object, not flat booleans
type Features struct {
...
HistoricalData HistoricalDataSettings `json:"historical_data"`
}
type HistoricalDataSettings struct {
Uptime bool `json:"uptime"`
Vulnerabilities bool `json:"vulnerabilities"`
}
Rather than EnableUptimeHistoricalData bool /
EnableVulnerabilitiesHistoricalData bool at the top level of Features.
Rationale: the dataset catalog is growing — policy compliance is next on
deck per the project notes. A flat-boolean approach pollutes the top-level
Features namespace and makes GitOps YAML noisier with each addition. The
nested object keeps all dataset toggles under one key and makes per-dataset
iteration trivial.
2. Config key vs internal dataset name — explicit mapping helper
The issue mandates the config keys uptime and vulnerabilities. The
internal dataset names (used in DB rows, API paths, code) are uptime and
cve. The mismatch needs a single mapping point.
Considered alternatives:
- Implicit mapping in field names + JSON tags — works, but every consumer
must hardcode the
cve → Vulnerabilitiestranslation. Easy to drift. - Map-shaped settings (
map[string]bool) keyed by config-key strings — loses Go type safety on YAML unmarshal; strict decoding can't validate unknown sub-keys. ConfigKey()method on theDatasetinterface — couples the dataset abstraction to a config layer concern.- Rename the internal dataset (
cve→vulnerabilities) — would ripple into DB rows, existing API paths, the cve-chart change, and existing tests.
Chosen: a method on the settings type itself.
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)
}
}
Rationale:
- Keeps the struct shape (good for YAML strict decoding and grep-able field references on the Go side).
- One greppable place to update when a dataset is added.
- Safelist switch — no string interpolation into JSON paths or SQL.
- Returns an explicit error for unknown datasets, so a caller can surface a programmer error instead of silently treating it as "disabled."
- Lives on the type that owns the data, not on a separate adapter or the
Datasetinterface; theDatasetinterface stays focused on collection.
3. Defaults true for new installs and upgrades — and a one-time backfill
Features.ApplyDefaults() sets HistoricalData.Uptime = true and
HistoricalData.Vulnerabilities = true.
ApplyDefaultsForNewInstalls() already delegates to ApplyDefaults().
The datastore code calls these methods before unmarshaling stored JSON
on both read paths — appConfigDB and teamFeaturesDB. So existing rows
whose stored JSON simply omits historical_data read back with both
sub-fields true.
The catch: stored false defeats the pre-unmarshal default. Earlier
migrations such as 20260427134220 use the updateAppConfigJSON helper
(and team-config equivalents use the same inline pattern), which:
- unmarshals the stored JSON into
fleet.AppConfig, - runs the callback, and
- re-marshals the whole struct back to the row.
The moment HistoricalData exists as a struct field in Go, step 3
persists its zero value (false) into stored JSON for every existing
row, even though the migration's callback never touched it. Subsequent
reads then see explicit false and the pre-unmarshal default loses.
Plain bool can't distinguish "not set" from "explicit false," so we
can't recover the intended default at read time. Three ways out:
- (A) backfill migration that writes
historical_data: {true, true}on every existing row. Chosen. - (B) refactor
updateAppConfigJSONto doJSON_SETinstead of struct round-trip, which addresses the trap class for every field. The right long-term fix but a large blast radius (rewriting ~12 merged migrations). Filed as follow-up backlog work. - (C)
optjson.Boolstorage type for the sub-keys, plus a post-unmarshalFillDefaultsstep. Future-proofs additions toHistoricalDataSettingsspecifically (e.g., the next dataset toggle) but doesn't help any other plain-bool field added later, and pullsoptjson.Boolinto a wider surface (test literals, GET responses, spec wording). Considered and rejected as disproportionate for this PR.
The "any-window clobber" risk that normally complicates a backfill
migration — that an admin could deliberately set false between when
this code ships and when the backfill runs, only for the backfill to
clobber it — does not apply here: this code lands in 4.85.0, which is
the first release exposing the toggle. No admin can have set false
prior to the backfill running.
The backfill is co-located with migration 20260423161823_AddHostSCDData
(the chart data table), so the same migration that introduces chart
storage also turns the toggles on. AppConfig uses updateAppConfigJSON
(safe here because the values being written are non-zero, so the
round-trip preserves them); team configs use JSON_MERGE_PATCH to add
or replace features.historical_data per row without round-tripping
the whole TeamConfig struct.
Rationale for defaults true: existing deployments should keep
getting the charts they'll start seeing once the dashboard chart UI
lands. Defaulting off on upgrade would silently break the "dashboards
just work" story. EnableSoftwareInventory defaulting on only for new
installs is a different concern (consent for privacy-sensitive
collection); the historical-data rollups here are derived from data
Fleet already collects, so there's no consent wrinkle.
4. PATCH merge — global is free, fleet uses an optjson.Bool payload subset
The global and fleet endpoints have structurally different request decoders, so PATCH merge requires different mechanisms:
Global (POST /api/v1/fleet/config): ModifyAppConfig takes raw JSON
bytes (p []byte) and unmarshals them into the existing config. Go's
JSON decoder recurses into nested structs and only touches fields present
in the payload. So {"features": {"historical_data": {"vulnerabilities": false}}} flips vulnerabilities and leaves uptime untouched. No
custom merge logic.
Fleet (PATCH /api/v1/fleet/fleets/{id}): ModifyTeam takes a
parsed TeamPayload, not raw bytes. TeamPayload has no Features
field today, so features.historical_data would be silently dropped.
Two ways to wire it:
- Refactor
ModifyTeamto take raw JSON — large surface change, touches every other field on the endpoint. - Add a focused
Featuresfield toTeamPayload— small surface change, follows the existingMDM *TeamPayloadMDM/WebhookSettings *TeamWebhookSettingspattern.
Option 2 chosen. New types in server/fleet/teams.go:
type TeamPayloadFeatures struct {
HistoricalData *HistoricalDataPayload `json:"historical_data"`
}
type HistoricalDataPayload struct {
Uptime optjson.Bool `json:"uptime"`
Vulnerabilities optjson.Bool `json:"vulnerabilities"`
}
TeamPayloadFeatures is a payload-only subset of Features. Only the
sub-fields it declares can be written via this endpoint; other Features
fields (enable_host_users, enable_software_inventory,
additional_queries, detail_query_overrides) remain settable per-fleet
only via /spec/fleets. The narrow surface keeps a new auth-review
question off the table for v1: this endpoint can already set fleet
identity and integrations, but historically not Features.
HistoricalDataPayload uses optjson.Bool per sub-field instead of plain
bool so a sub-key omitted from the PATCH body retains its current
stored value (Valid == false), while a sub-key explicitly sent as
false flips the stored value (Valid == true, Value == false). This is
exactly how MDM.EnableDiskEncryption already behaves on this endpoint.
ModifyTeam applies the partial:
oldHistoricalData := team.Config.Features.HistoricalData
if payload.Features != nil && payload.Features.HistoricalData != nil {
if payload.Features.HistoricalData.Uptime.Valid {
team.Config.Features.HistoricalData.Uptime = payload.Features.HistoricalData.Uptime.Value
}
if payload.Features.HistoricalData.Vulnerabilities.Valid {
team.Config.Features.HistoricalData.Vulnerabilities = payload.Features.HistoricalData.Vulnerabilities.Value
}
}
// SaveTeam, then diff old vs new and emit activities.
The wire shape is identical to global
({features: {historical_data: {...}}}), the storage location is
identical (team.Config.Features.HistoricalData), and the GET response
shape is identical (features.historical_data on the read side, by
virtue of Team.MarshalJSON embedding TeamConfig). Only the decoder
plumbing differs.
Strict decoding: the global /config endpoint uses
appConfig.EnableStrictDecoding() to reject unknown fields. The fleet
PATCH endpoint has no such mechanism today; unknown sub-fields under
features (e.g. an admin trying features.enable_host_users) are
silently ignored. That's the existing convention for this endpoint, and
this change does not introduce strict decoding for it.
GitOps overwrite + client-side defaulting:
ApplySpecOptions.Overwrite = true is what the gitops client passes when
applying global config or team specs (see server/service/client.go).
That mode replaces Features wholesale, which by itself would zero any
sub-key not explicitly present in the YAML — including
historical_data sub-keys, even though their ApplyDefaults value is
true. Concretely, an admin who runs fleetctl gitops with a YAML
that doesn't mention historical_data would see both datasets silently
disabled on every apply, contradicting the upgrade-friendly default of
true documented elsewhere.
To avoid that, the gitops client SHALL inject default true values for
any historical_data sub-key not explicitly set in the YAML, mirroring
the existing carve-out for enable_software_inventory at
server/service/client.go:1965-1978. The same defaulting SHALL be
applied on the team-spec gitops path. After this change:
- YAML with no
featuresblock → client addsfeatures.historical_data: {uptime: true, vulnerabilities: true}to the request payload. - YAML with a
featuresblock but nohistorical_data→ client adds the fullhistorical_data: {uptime: true, vulnerabilities: true}. - YAML with
historical_data: {uptime: false}→ client adds the missingvulnerabilities: truesub-key, leavinguptime: falseintact. - YAML with both sub-keys explicit → no change.
This injection is gitops-only. fleetctl apply uses
Overwrite=false, which is partial-merge by JSON unmarshal: omitted
fields are left at their prior stored value. No client-side injection
is needed for that path; trying to add one would silently re-enable
fields the admin has explicitly disabled via the API or UI, which is
the wrong default for apply.
5. Activity per sub-field, scoped with fleet_id / fleet_name
type ActivityTypeEnabledHistoricalDataset struct {
Dataset string `json:"dataset"`
FleetID *uint `json:"fleet_id"`
FleetName *string `json:"fleet_name"`
}
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 PATCHes emit
zero activities. Global emits with fleet_id / fleet_name nil;
per-fleet emits with both populated.
The dataset payload uses the config key, not the internal dataset
name — i.e. "vulnerabilities", not "cve". The audit log is admin-facing,
and admins encounter the config key in YAML and (eventually) the UI.
Surfacing the internal name would force admins to learn an undocumented
translation.
Rationale: matches the existing "global or fleet-scoped" activity
pattern (e.g. ActivityTypeEnabledMacosDiskEncryption at
server/fleet/activities.go:754). No renameto JSON tags because these
types are new and have no legacy naming to migrate from.
6. Effective-value semantics live in the consumer, not here
A dataset is collected for a host IFF
global.HistoricalData.<field> AND fleet(host).HistoricalData.<field>.
No-team hosts follow the global value directly.
This change does not enforce that semantic — it ships the data shape and
the mapping helper. The cron-side enforcement (which loads global +
team-scoped configs and computes the enabled-fleet set per dataset) lives
in a separate change against server/chart/. Reason: the cron lives on
the dashboard-charts-backend branch, has its own datastore adapter, and is
naturally where the AND rule executes.
This change documents the AND rule in the spec deltas (so the contract is captured even though it isn't yet enforced) but does not add cron code.
Risks / Trade-offs
- [Trade-off] GitOps re-enables out-of-band disables. Because the
client injects
historical_data: {uptime: true, vulnerabilities: true}defaults whenever the YAML doesn't explicitly set them, an admin who disables a dataset via the API or UI on a GitOps-managed deployment will see the nextfleetctl gitops applyre-enable it — unless the YAML pins the disabled value. This is the same trade-offenable_software_inventorycarries today and is the right default for the upgrade-friendly "dashboards just work" intent: GitOps state is the source of truth on those deployments, and out-of-band disables are unsanctioned. Documented in the YAML reference: "If you manage Fleet via GitOps and want a dataset disabled, includehistorical_dataexplicitly in your YAML — otherwise each apply defaults to enabled." - [Risk] Polarity confusion in activities vs UI. The future UI inverts
the bool to "Disable X" checkboxes, but the API and audit log use the
positive
enabled / disabledpolarity directly. An admin who readsdisabled_historical_dataset { dataset: "vulnerabilities" }sees the same word "disabled" they clicked in the UI; the polarity matches. The trap is internal: a code reader who seesVulnerabilities: falsein a config might forget that means "disabled, do not collect." Mitigated by Go field naming (HistoricalData.Vulnerabilitiesreads as "is vulnerabilities historical-data on?") and by theEnabled(dataset)helper enforcing the read pattern. - [Trade-off] Two activity types vs one with a verb payload. The audit
log gains one "verb" per toggle direction. Existing tooling that scrapes
activity types by name needs to know about the new types. Accepted:
Fleet has many
enabled_*/disabled_*pairs and integrators recognize the pattern. - [Trade-off] Cron consumer arrives separately. Once this change
merges,
historical_datais settable but not enforced — disabling a dataset has no effect on collection until the cron-gating change lands. The activities and config still record correctly. Mitigation: land the cron-gating change close behind, and don't surface UI checkboxes (a third change) until at least the cron gating is in. - [Risk] Strict decoding rejects unknown dataset keys. A YAML or JSON
payload with a typo (
historical_data: { vulnerabilites: false }) is rejected byEnableStrictDecoding. This is the desired behavior — the alternative is silently ignoring the typo and leaving the dataset enabled. Documented in the YAML reference.