GitOps support for scheduled auto updates settings (#37851)

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

# Checklist for submitter

- [ ] 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.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] 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

### `fleetctl generate-gitops`

**UI**

<img width="1866" height="787" alt="Screenshot 2026-01-07 at 1 14 45 PM"
src="https://github.com/user-attachments/assets/ccb585ee-3074-4ebf-9aaf-6dbdb885cf89"
/>

**Generated YAML**

<img width="694" height="354" alt="Screenshot 2026-01-07 at 1 15 23 PM"
src="https://github.com/user-attachments/assets/3867b63d-265d-43bc-bd60-31fb14e83409"
/>

### `fleetctl gitops`

**Source YAML**

I set the start_time to 18:30 and end_time to 19:30 for the first app

<img width="359" height="354" alt="Screenshot 2026-01-07 at 1 18 33 PM"
src="https://github.com/user-attachments/assets/4cde31e3-aa93-4b75-b296-cc101f507c2a"
/>


After the command ran, verified both on the UI and DB that the new
values were set

<img width="790" height="185" alt="Screenshot 2026-01-07 at 1 18 56 PM"
src="https://github.com/user-attachments/assets/30d9806c-b26c-4f17-b505-facb7fd0df15"
/>

<img width="1432" height="708" alt="Screenshot 2026-01-07 at 1 19 15 PM"
src="https://github.com/user-attachments/assets/5e123f94-ea69-4403-bba3-abf917cc1a01"
/>


## 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/blob/a79ff22d84466106fccc3818d38f23b9d9dc62df/docs/Configuration/yaml-files.md?plain=1#L538-L540
- [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

## fleetd/orbit/Fleet Desktop

- [ ] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [ ] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [ ] Verified that fleetd runs on macOS, Linux and Windows
- [ ] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))
This commit is contained in:
Nico
2026-01-09 12:17:03 -03:00
committed by GitHub
parent a3c55d936c
commit 227bcdf46f
15 changed files with 970 additions and 101 deletions
@@ -144,6 +144,14 @@ func (s *enterpriseIntegrationGitopsTestSuite) TearDownTest() {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
return err
})
vppTokens, err := s.DS.ListVPPTokens(ctx)
require.NoError(t, err)
for _, tok := range vppTokens {
err := s.DS.DeleteVPPToken(ctx, tok.ID)
require.NoError(t, err)
}
mysql.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;")
return err
@@ -2711,3 +2719,212 @@ func labelTeamIDResult(t *testing.T, s *enterpriseIntegrationGitopsTestSuite, ct
}
return got
}
// TestGitOpsVPPAppAutoUpdate tests that auto-update settings for VPP apps (iOS/iPadOS)
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsVPPAppAutoUpdate() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a global VPP token (location is "Jungle")
test.CreateInsertGlobalVPPToken(t, s.DS)
// Generate team name upfront since we need it in the global template
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := fmt.Sprintf(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
queries:
`, teamName)
teamTemplate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_start_time: "02:00"
auto_update_end_time: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
auto_update_enabled: true
auto_update_start_time: "03:00"
auto_update_end_time: "07:00"
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
dryRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify VPP apps were added
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2) // One for iOS, one for iPadOS
// Verify auto-update schedules were created in the database
type autoUpdateSchedule struct {
TitleID uint `db:"title_id"`
TeamID uint `db:"team_id"`
Enabled bool `db:"enabled"`
StartTime string `db:"start_time"`
EndTime string `db:"end_time"`
}
var schedules []autoUpdateSchedule
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &schedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, schedules, 2)
for _, schedule := range schedules {
require.Equal(t, team.ID, schedule.TeamID)
require.True(t, schedule.Enabled)
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
// Verify the correct start/end times based on source
switch foundTitle.Source {
case "ios_apps":
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
// Now apply a config without auto-update fields for the iPadOS app and verify they're cleared
teamTemplateNoAutoUpdate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_start_time: "02:00"
auto_update_end_time: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
teamFileNoAutoUpdate, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFileNoAutoUpdate.WriteString(fmt.Sprintf(teamTemplateNoAutoUpdate, teamName))
require.NoError(t, err)
err = teamFileNoAutoUpdate.Close()
require.NoError(t, err)
// Apply the updated config
realRunOutput = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileNoAutoUpdate.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
// Verify auto-update schedules: iOS should still have settings, iPadOS should be disabled
var updatedSchedules []autoUpdateSchedule
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &updatedSchedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, updatedSchedules, 2)
for _, schedule := range updatedSchedules {
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
switch foundTitle.Source {
case "ios_apps":
// iOS app should still have auto-update enabled
require.True(t, schedule.Enabled)
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
// iPadOS app should now have auto-update disabled (fields removed from config)
// but the previous start/end times should still be preserved in the database
require.False(t, schedule.Enabled)
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
}