Add aliases for macos fields (#40959)

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

# Details

Implements the renames requested in #40488:

- [X] Add a second name for `macos_setup`: `setup_experience`
- [X] Add a second name for `macos_settings`: `apple_settings`
- [X] Add a second name for `custom_settings`: `configuration_profiles`
- [X] Add a second name for `macos_setup_assistant`:
`apple_setup_assistant`

Prior names are deprecated and log warnings. This uses the same
`renameto` tags as previous aliases, and adds code in relevant sections
in gitops.go to run the existing "rename new to old keys" function so
that we can unmarshall into the existing structs (that still have their
`json` tags set to the old key names until Fleet 5).

# Checklist for submitter

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

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

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
- [X] Ran current it-and-security GitOps files successfully locally
(removing mdm stuff that wouldn't work for me locally, but wasn't
relevant to the updated keys
- [X] Run same files successfully after changing the deprecated key
names to their new aliases
    - [X] Verified that new keys show up in API responses:
<img width="506" height="243" alt="image"
src="https://github.com/user-attachments/assets/db1eb522-a702-4d17-b313-81ca203632b6"
/>


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`
- [ ] 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)
- [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
n/a


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

* **New Features**
* Introduces new configuration key aliases: apple_settings (macOS),
configuration_profiles (profiles for macOS/Windows/Android),
setup_experience (macOS setup), and apple_setup_assistant (macOS setup
assistant).
* Old configuration keys remain supported for backward compatibility;
tooling and generated controls will accept either the new or legacy
names.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
This commit is contained in:
Scott Gress
2026-03-05 18:08:54 -06:00
committed by GitHub
co-authored by Ian Littman
parent b9ef13760e
commit 51ab583e9e
15 changed files with 458 additions and 41 deletions
+29 -4
View File
@@ -148,8 +148,8 @@ type GitOpsControls struct {
MacOSUpdates any `json:"macos_updates"`
IOSUpdates any `json:"ios_updates"`
IPadOSUpdates any `json:"ipados_updates"`
MacOSSettings any `json:"macos_settings"`
MacOSSetup *fleet.MacOSSetup `json:"macos_setup"`
MacOSSettings any `json:"macos_settings" renameto:"apple_settings"`
MacOSSetup *fleet.MacOSSetup `json:"macos_setup" renameto:"setup_experience"`
MacOSMigration any `json:"macos_migration"`
WindowsUpdates any `json:"windows_updates"`
@@ -747,13 +747,18 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, multiError *m
return multiError
}
controlsRaw, _, err := rewriteNewToOldKeys(controlsRaw, &GitOpsControls{})
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to rewrite controls keys: %v", err))
}
var controlsTop GitOpsControls
if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
return multierror.Append(multiError, MaybeParseTypeError(yamlFilename, []string{"controls"}, err))
}
controlsTop.Defined = true
controlsFilePath := yamlFilename
err := processControlsPathIfNeeded(controlsTop, result, &controlsFilePath)
err = processControlsPathIfNeeded(controlsTop, result, &controlsFilePath)
if err != nil {
return multierror.Append(multiError, err)
}
@@ -801,6 +806,10 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, multiError *m
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.macos_settings: %v", err))
}
data, _, err = rewriteNewToOldKeys(data, &macOSSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to rewrite macos_settings keys: %v", err))
}
err = json.Unmarshal(data, &macOSSettings)
if err != nil {
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "macos_settings"}, err))
@@ -823,6 +832,10 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, multiError *m
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.windows_settings: %v", err))
}
data, _, err = rewriteNewToOldKeys(data, &windowsSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to rewrite windows_settings keys: %v", err))
}
err = json.Unmarshal(data, &windowsSettings)
if err != nil {
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "windows_settings"}, err))
@@ -847,6 +860,10 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, multiError *m
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to process controls.android_settings: %v", err))
}
data, _, err = rewriteNewToOldKeys(data, &androidSettings)
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to rewrite android_settings keys: %v", err))
}
err = json.Unmarshal(data, &androidSettings)
if err != nil {
return multierror.Append(multiError, MaybeParseTypeError(controlsFilePath, []string{"controls", "android_settings"}, err))
@@ -902,7 +919,15 @@ func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, con
}
var pathControls GitOpsControls
if err := YamlUnmarshal(fileBytes, &pathControls); err != nil {
jsonBytes, err := yaml.YAMLToJSON(fileBytes)
if err != nil {
return MaybeParseTypeError(*controlsTop.Path, []string{"controls"}, fmt.Errorf("failed to unmarshal YAML to JSON: %w", err))
}
jsonBytes, _, err = rewriteNewToOldKeys(jsonBytes, &GitOpsControls{})
if err != nil {
return fmt.Errorf("failed to rewrite controls keys in %s: %v", *controlsTop.Path, err)
}
if err := json.Unmarshal(jsonBytes, &pathControls); err != nil {
return MaybeParseTypeError(*controlsTop.Path, []string{"controls"}, err)
}
if pathControls.Path != nil {
+316
View File
@@ -1932,6 +1932,322 @@ func TestGitOpsGlobScripts(t *testing.T) {
assert.Equal(t, filepath.Join(scriptsDir, "gamma.ps1"), *result.Controls.Scripts[2].Path)
}
// TestControlsNewKeyNames verifies that the new multi-platform key names
// (apple_settings, setup_experience, configuration_profiles, apple_setup_assistant)
// are accepted in controls parsing and produce the same result as the old names.
func TestControlsNewKeyNames(t *testing.T) {
t.Parallel()
// Test with inline controls using new key names
t.Run("inline_new_names", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "macos-password.mobileconfig"), []byte("<plist></plist>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte("<xml/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "collect-fleetd-logs.sh"), []byte("#!/bin/bash"), 0o644))
config := `
controls:
apple_settings:
configuration_profiles:
- path: ./lib/macos-password.mobileconfig
windows_settings:
configuration_profiles:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
setup_experience:
bootstrap_package: null
enable_end_user_authentication: false
apple_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
reports:
policies:
agent_options:
org_settings:
server_settings:
server_url: https://fleet.example.com
org_info:
contact_url: https://example.com/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: Test Org
secrets:
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
gitops, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
// Verify controls parsed correctly with new key names
macSettings, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings)
require.True(t, ok, "macos_settings (via apple_settings) not parsed")
require.Len(t, macSettings.CustomSettings, 1)
winSettings, ok := gitops.Controls.WindowsSettings.(fleet.WindowsSettings)
require.True(t, ok, "windows_settings not parsed")
require.True(t, winSettings.CustomSettings.Valid)
require.Len(t, winSettings.CustomSettings.Value, 1)
require.NotNil(t, gitops.Controls.MacOSSetup, "macos_setup (via setup_experience) not parsed")
diskEnc, ok := gitops.Controls.EnableDiskEncryption.(bool)
require.True(t, ok)
require.True(t, diskEnc)
})
// Test with external controls file using new key names
t.Run("external_file_new_names", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "macos-password.mobileconfig"), []byte("<plist></plist>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte("<xml/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "collect-fleetd-logs.sh"), []byte("#!/bin/bash"), 0o644))
controlsYAML := `
apple_settings:
configuration_profiles:
- path: ./lib/macos-password.mobileconfig
windows_settings:
configuration_profiles:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
setup_experience:
bootstrap_package: null
enable_end_user_authentication: false
apple_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
`
controlsPath := filepath.Join(dir, "controls.yml")
require.NoError(t, os.WriteFile(controlsPath, []byte(controlsYAML), 0o644))
config := `
controls:
path: ./controls.yml
reports:
policies:
agent_options:
org_settings:
server_settings:
server_url: https://fleet.example.com
org_info:
contact_url: https://example.com/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: Test Org
secrets:
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
gitops, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.NoError(t, err)
// Verify controls parsed correctly from external file with new key names
macSettings, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings)
require.True(t, ok, "macos_settings (via apple_settings in external file) not parsed")
require.Len(t, macSettings.CustomSettings, 1)
winSettings, ok := gitops.Controls.WindowsSettings.(fleet.WindowsSettings)
require.True(t, ok, "windows_settings not parsed")
require.True(t, winSettings.CustomSettings.Valid)
require.Len(t, winSettings.CustomSettings.Value, 1)
require.NotNil(t, gitops.Controls.MacOSSetup, "macos_setup (via setup_experience in external file) not parsed")
})
// Test that duplicate settings with old and new key names produce an error
t.Run("duplicate_old_and_new_keys_error_apple_settings", func(t *testing.T) {
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
config := `
reports:
policies:
agent_options:
org_settings:
server_settings:
org_info:
secrets:
controls:
apple_settings:
configuration_profiles:
- path: ./lib/macos-password.mobileconfig
macos_settings:
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "apple_settings")
require.Contains(t, err.Error(), "`macos_settings` (deprecated)")
})
t.Run("duplicate_old_and_new_keys_error_apple_custom_settings", func(t *testing.T) {
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
config := `
reports:
policies:
agent_options:
org_settings:
server_settings:
org_info:
secrets:
controls:
apple_settings:
configuration_profiles:
- path: ./lib/macos-password.mobileconfig
custom_settings:
- path: ./lib/macos-password.mobileconfig
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "configuration_profiles")
require.Contains(t, err.Error(), "`custom_settings` (deprecated)")
})
t.Run("duplicate_old_and_new_keys_error_windows_custom_settings", func(t *testing.T) {
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
config := `
reports:
policies:
agent_options:
org_settings:
server_settings:
org_info:
secrets:
controls:
windows_settings:
configuration_profiles:
- path: ./lib/foo
custom_settings:
- path: ./lib/bar
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "configuration_profiles")
require.Contains(t, err.Error(), "`custom_settings` (deprecated)")
})
t.Run("duplicate_old_and_new_keys_error_android_custom_settings", func(t *testing.T) {
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
config := `
reports:
policies:
agent_options:
org_settings:
server_settings:
org_info:
secrets:
controls:
android_settings:
configuration_profiles:
- path: ./lib/foo
custom_settings:
- path: ./lib/bar
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "configuration_profiles")
require.Contains(t, err.Error(), "`custom_settings` (deprecated)")
})
t.Run("duplicate_old_and_new_keys_error_setup_experience", func(t *testing.T) {
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
config := `
reports:
policies:
agent_options:
org_settings:
server_settings:
org_info:
secrets:
controls:
setup_experience:
macos_setup:
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "setup_experience")
require.Contains(t, err.Error(), "`macos_setup` (deprecated)")
})
t.Run("duplicate_keys_external_file", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
profileDir := filepath.Join(dir, "lib")
require.NoError(t, os.Mkdir(profileDir, 0o755))
controlsYAML := `
apple_settings:
macos_settings:
`
controlsPath := filepath.Join(dir, "controls.yml")
require.NoError(t, os.WriteFile(controlsPath, []byte(controlsYAML), 0o644))
config := `
controls:
path: ./controls.yml
reports:
policies:
agent_options:
org_settings:
secrets:
`
yamlPath := filepath.Join(dir, "gitops.yml")
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
require.Error(t, err)
require.Contains(t, err.Error(), "Conflicting field names")
require.Contains(t, err.Error(), "apple_settings")
require.Contains(t, err.Error(), "`macos_settings` (deprecated)")
})
}
func TestSoftwarePackagesScriptPath(t *testing.T) {
t.Parallel()
appConfig := &fleet.EnrichedAppConfig{}
+37
View File
@@ -1,6 +1,7 @@
package spec
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
@@ -8,6 +9,8 @@ import (
"testing"
"github.com/fleetdm/fleet/v4/pkg/testutils"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/hashicorp/go-multierror"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -346,3 +349,37 @@ func TestGetExclusionZones(t *testing.T) {
}
})
}
func TestRewriteNewToOldKeys(t *testing.T) {
t.Run("accepts old keys", func(t *testing.T) {
raw := json.RawMessage(`{"name":"test","team":"myteam"}`)
result, _, err := rewriteNewToOldKeys(raw, fleet.QuerySpec{})
require.NoError(t, err)
var qs fleet.QuerySpec
require.NoError(t, json.Unmarshal(result, &qs))
assert.Equal(t, "test", qs.Name)
assert.Equal(t, "myteam", qs.TeamName)
})
t.Run("accepts new keys", func(t *testing.T) {
raw := json.RawMessage(`{"name":"test","fleet":"myteam"}`)
result, _, err := rewriteNewToOldKeys(raw, fleet.QuerySpec{})
require.NoError(t, err)
var qs fleet.QuerySpec
require.NoError(t, json.Unmarshal(result, &qs))
assert.Equal(t, "test", qs.Name)
assert.Equal(t, "myteam", qs.TeamName)
})
t.Run("errors if both old and new keys provided", func(t *testing.T) {
raw := json.RawMessage(`{"name":"test","team":"old","fleet":"new"}`)
_, _, err := rewriteNewToOldKeys(raw, fleet.QuerySpec{})
require.Error(t, err)
var conflictErr *endpointer.AliasConflictError
require.ErrorAs(t, err, &conflictErr)
assert.Equal(t, "team", conflictErr.Old)
assert.Equal(t, "fleet", conflictErr.New)
})
}
+33
View File
@@ -0,0 +1,33 @@
apple_settings:
configuration_profiles:
- path: ./lib/macos-password.mobileconfig
windows_settings:
configuration_profiles:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
macos_migration:
enable: false
mode: ""
webhook_url: ""
setup_experience:
bootstrap_package: null
enable_end_user_authentication: false
apple_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
ios_updates:
deadline: null
minimum_version: null
ipados_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
windows_updates:
deadline_days: null
grace_period_days: null