CSAH: appconfig/gitops/DB migration to add preserve_host_activities_on_reenrollment field (#44212)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43943 

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

See
https://github.com/fleetdm/fleet/issues/43943#issuecomment-4329658412

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.

## New Fleet configuration 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)
(see https://github.com/fleetdm/fleet/pull/43877/changes)
- [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)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled (should be done by
https://github.com/fleetdm/fleet/issues/43947)



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

* **New Features**
* Added a configuration option to preserve host activities during host
re-enrollment, letting admins choose whether activity history is
retained when hosts re-enroll.

* **Chores**
* Updated defaults and database migration state so the new setting is
present in stored and generated configs and in GitOps outputs.

* **Tests**
* Added unit, integration, migration, and GitOps fixtures to validate
behavior, serialization, and upgrade semantics.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Martin Angers
2026-04-28 08:47:38 -04:00
committed by GitHub
parent 24e04a41c2
commit 2c609ae78e
25 changed files with 463 additions and 13 deletions
+7 -6
View File
@@ -793,12 +793,13 @@ func (cmd *GenerateGitopsCommand) generateOrgSettings() (orgSettings map[string]
}
orgSettings = map[string]interface{}{
jsonFieldName(t, "Features"): cmd.AppConfig.Features,
jsonFieldName(t, "FleetDesktop"): cmd.AppConfig.FleetDesktop,
jsonFieldName(t, "HostExpirySettings"): cmd.AppConfig.HostExpirySettings,
jsonFieldName(t, "OrgInfo"): cmd.AppConfig.OrgInfo,
jsonFieldName(t, "ServerSettings"): cmd.AppConfig.ServerSettings,
jsonFieldName(t, "WebhookSettings"): webhookSettings,
jsonFieldName(t, "ActivityExpirySettings"): cmd.AppConfig.ActivityExpirySettings,
jsonFieldName(t, "Features"): cmd.AppConfig.Features,
jsonFieldName(t, "FleetDesktop"): cmd.AppConfig.FleetDesktop,
jsonFieldName(t, "HostExpirySettings"): cmd.AppConfig.HostExpirySettings,
jsonFieldName(t, "OrgInfo"): cmd.AppConfig.OrgInfo,
jsonFieldName(t, "ServerSettings"): cmd.AppConfig.ServerSettings,
jsonFieldName(t, "WebhookSettings"): webhookSettings,
}
integrations, err := cmd.generateIntegrations("default.yml", &GlobalOrTeamIntegrations{GlobalIntegrations: &cmd.AppConfig.Integrations})
@@ -1033,6 +1033,39 @@ func TestGenerateGitopsFree(t *testing.T) {
})
}
func TestGenerateGitopsPreserveHostActivitiesOnReenrollment(t *testing.T) {
// Verifies that fleetctl generate-gitops emits
// activity_expiry_settings.preserve_host_activities_on_reenrollment so
// existing customers can roundtrip the setting via GitOps.
fleetClient := &MockClient{}
appConfig, err := fleetClient.GetAppConfig()
require.NoError(t, err)
// Sanity-check the source config matches what the generated output should
// expose.
require.True(t, appConfig.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
cmd := &GenerateGitopsCommand{
Client: fleetClient,
CLI: cli.NewContext(&cli.App{}, nil, nil),
Messages: Messages{},
FilesToWrite: make(map[string]any),
AppConfig: appConfig,
}
orgSettingsRaw, err := cmd.generateOrgSettings()
require.NoError(t, err)
b, err := yamlMarshalRenamed(orgSettingsRaw)
require.NoError(t, err)
var orgSettings map[string]any
require.NoError(t, yaml.Unmarshal(b, &orgSettings))
aes, ok := orgSettings["activity_expiry_settings"].(map[string]any)
require.True(t, ok, "activity_expiry_settings should be present in generated org settings")
require.Equal(t, true, aes["preserve_host_activities_on_reenrollment"])
}
func TestGenerateOrgSettings(t *testing.T) {
// Get the test app config.
fleetClient := &MockClient{}
+33
View File
@@ -3844,6 +3844,39 @@ func TestGitOpsWindowsMigration(t *testing.T) {
}
}
func TestGitOpsPreserveHostActivitiesOnReenrollment(t *testing.T) {
t.Run("explicit true", func(t *testing.T) {
_, appConfig, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
_, err := RunAppNoChecks([]string{"gitops", "-f", "testdata/gitops/global_config_preserve_host_activities_true.yml"})
require.NoError(t, err)
require.True(t, (*appConfig).ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
})
t.Run("explicit false", func(t *testing.T) {
_, appConfig, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
// Seed the AppConfig with true so we can confirm gitops actually flips it.
(*appConfig).ActivityExpirySettings.PreserveHostActivitiesOnReenrollment = true
_, err := RunAppNoChecks([]string{"gitops", "-f", "testdata/gitops/global_config_preserve_host_activities_false.yml"})
require.NoError(t, err)
require.False(t, (*appConfig).ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
})
t.Run("omitted preserves prior value", func(t *testing.T) {
_, appConfig, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
// Seed the AppConfig with true so we can confirm gitops does not clobber
// the value when the field is absent from the YAML.
(*appConfig).ActivityExpirySettings.PreserveHostActivitiesOnReenrollment = true
_, err := RunAppNoChecks([]string{"gitops", "-f", "testdata/gitops/global_config_preserve_host_activities_omitted.yml"})
require.NoError(t, err)
require.True(t, (*appConfig).ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
})
}
func TestGitOpsGlobalWebhooksDisable(t *testing.T) {
_, appConfig, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
@@ -42,7 +42,8 @@
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
"activity_expiry_window": 0,
"preserve_host_activities_on_reenrollment": false
},
"conditional_access": {
"microsoft_entra_tenant_id": "",
@@ -27,7 +27,8 @@
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
"activity_expiry_window": 0,
"preserve_host_activities_on_reenrollment": false
},
"conditional_access": {
"microsoft_entra_tenant_id": "",
@@ -11,6 +11,7 @@ spec:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 0
preserve_host_activities_on_reenrollment: false
conditional_access:
microsoft_entra_tenant_id: ""
microsoft_entra_connection_configured: false
@@ -11,6 +11,7 @@ spec:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 0
preserve_host_activities_on_reenrollment: false
conditional_access:
microsoft_entra_tenant_id: ""
microsoft_entra_connection_configured: false
@@ -42,7 +42,8 @@
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
"activity_expiry_window": 0,
"preserve_host_activities_on_reenrollment": false
},
"conditional_access": {
"microsoft_entra_tenant_id": "",
@@ -11,6 +11,7 @@ spec:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 0
preserve_host_activities_on_reenrollment: false
conditional_access:
microsoft_entra_tenant_id: ""
microsoft_entra_connection_configured: false
@@ -100,7 +100,8 @@
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 30
"activity_expiry_window": 30,
"preserve_host_activities_on_reenrollment": true
},
"features": {
"enable_host_users": true,
@@ -1,3 +1,7 @@
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 30
preserve_host_activities_on_reenrollment: true
certificate_authorities:
custom_est_proxy:
- name: some-est-name
@@ -1,3 +1,7 @@
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 30
preserve_host_activities_on_reenrollment: true
certificate_authorities:
custom_est_proxy:
- name: some-est-name
@@ -49,6 +49,10 @@ labels:
label_membership_type: host_vitals
name: Label C
org_settings:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 30
preserve_host_activities_on_reenrollment: true
certificate_authorities:
custom_est_proxy:
- name: some-est-name
@@ -31,6 +31,10 @@ labels:
label_membership_type: host_vitals
name: Label C
org_settings:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 30
preserve_host_activities_on_reenrollment: true
certificate_authorities:
custom_est_proxy:
- name: some-est-name
@@ -0,0 +1,78 @@
controls:
macos_settings:
windows_settings:
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
sso_settings:
integrations:
mdm:
end_user_authentication:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
preserve_host_activities_on_reenrollment: false
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: SampleSecret123
- secret: ABC
software:
@@ -0,0 +1,77 @@
controls:
macos_settings:
windows_settings:
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
sso_settings:
integrations:
mdm:
end_user_authentication:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: SampleSecret123
- secret: ABC
software:
@@ -0,0 +1,78 @@
controls:
macos_settings:
windows_settings:
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
sso_settings:
integrations:
mdm:
end_user_authentication:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
preserve_host_activities_on_reenrollment: true
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: SampleSecret123
- secret: ABC
software:
@@ -14,6 +14,7 @@ spec:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 0
preserve_host_activities_on_reenrollment: false
conditional_access:
microsoft_entra_tenant_id: ""
microsoft_entra_connection_configured: false
@@ -14,6 +14,7 @@ spec:
activity_expiry_settings:
activity_expiry_enabled: false
activity_expiry_window: 0
preserve_host_activities_on_reenrollment: false
conditional_access:
microsoft_entra_tenant_id: ""
microsoft_entra_connection_configured: false
@@ -0,0 +1,36 @@
package tables
import (
"database/sql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260427134220, Down_20260427134220)
}
func Up_20260427134220(tx *sql.Tx) error {
// Defaults to true for upgraded installations (where users already exist) so
// that prior behavior is preserved, and false for fresh installations.
var usersCount int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users;`).Scan(&usersCount); err != nil {
return errors.Wrap(err, "select count users")
}
preserve := usersCount > 0
if err := updateAppConfigJSON(tx, func(config *fleet.AppConfig) error {
if config != nil {
config.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment = preserve
}
return nil
}); err != nil {
return errors.Wrap(err, "set PreserveHostActivitiesOnReenrollment in AppConfig")
}
return nil
}
func Down_20260427134220(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,53 @@
package tables
import (
"encoding/json"
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestUp_20260427134220_FreshInstall(t *testing.T) {
db := applyUpToPrev(t)
// No users inserted: this represents a fresh installation.
applyNext(t, db)
var raw json.RawMessage
require.NoError(t, sqlx.Get(db, &raw, `SELECT json_value FROM app_config_json LIMIT 1;`))
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
aes, ok := cfg["activity_expiry_settings"].(map[string]any)
require.True(t, ok)
v, ok := aes["preserve_host_activities_on_reenrollment"].(bool)
require.True(t, ok)
require.False(t, v)
}
func TestUp_20260427134220_UpgradedInstall(t *testing.T) {
db := applyUpToPrev(t)
// At least one user exists: this represents an upgrade.
execNoErr(t, db,
`INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?);`,
"admin", "admin@example.com", "p", "s",
)
applyNext(t, db)
var raw json.RawMessage
require.NoError(t, sqlx.Get(db, &raw, `SELECT json_value FROM app_config_json LIMIT 1;`))
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
aes, ok := cfg["activity_expiry_settings"].(map[string]any)
require.True(t, ok)
v, ok := aes["preserve_host_activities_on_reenrollment"].(bool)
require.True(t, ok)
require.True(t, v)
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -1239,6 +1239,12 @@ type HostExpirySettings struct {
type ActivityExpirySettings struct {
ActivityExpiryEnabled bool `json:"activity_expiry_enabled"`
ActivityExpiryWindow int `json:"activity_expiry_window"`
// PreserveHostActivitiesOnReenrollment controls whether existing host
// activities, MDM commands, etc. are kept when a managed host re-enrolls.
// Defaults to true for upgraded installs (preserves prior behavior) and
// false for fresh installs.
PreserveHostActivitiesOnReenrollment bool `json:"preserve_host_activities_on_reenrollment"`
}
type Features struct {
+29
View File
@@ -8066,6 +8066,35 @@ func (s *integrationTestSuite) TestAppConfig() {
require.True(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled)
require.Equal(t, 42, acResp.ActivityExpirySettings.ActivityExpiryWindow)
// preserve_host_activities_on_reenrollment round-trip.
initialPreserve := acResp.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"activity_expiry_settings": {
"preserve_host_activities_on_reenrollment": true
}
}`), http.StatusOK, &acResp)
require.True(t, acResp.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
require.True(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled)
require.Equal(t, 42, acResp.ActivityExpirySettings.ActivityExpiryWindow)
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"activity_expiry_settings": {
"preserve_host_activities_on_reenrollment": false
}
}`), http.StatusOK, &acResp)
require.False(t, acResp.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
// Restore initial value to keep subsequent tests order-independent.
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"activity_expiry_settings": {
"preserve_host_activities_on_reenrollment": %t
}
}`, initialPreserve)), http.StatusOK, &acResp)
require.Equal(t, initialPreserve, acResp.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment)
// Disable AI features.
acResp = appConfigResponse{}
s.DoJSON(
@@ -33,6 +33,7 @@ github.com/fleetdm/fleet/v4/server/fleet/HostExpirySettings HostExpiryWindow int
github.com/fleetdm/fleet/v4/server/fleet/AppConfig ActivityExpirySettings fleet.ActivityExpirySettings
github.com/fleetdm/fleet/v4/server/fleet/ActivityExpirySettings ActivityExpiryEnabled bool
github.com/fleetdm/fleet/v4/server/fleet/ActivityExpirySettings ActivityExpiryWindow int
github.com/fleetdm/fleet/v4/server/fleet/ActivityExpirySettings PreserveHostActivitiesOnReenrollment bool
github.com/fleetdm/fleet/v4/server/fleet/AppConfig Features fleet.Features
github.com/fleetdm/fleet/v4/server/fleet/Features EnableHostUsers bool
github.com/fleetdm/fleet/v4/server/fleet/Features EnableSoftwareInventory bool