diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index d4c5a889762..afb2dca4021 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -1,6 +1,7 @@
package spec
import (
+ "bytes"
"encoding/json"
"errors"
"fmt"
@@ -16,6 +17,8 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/ghodss/yaml"
"github.com/hashicorp/go-multierror"
@@ -1206,9 +1209,86 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y
result.Controls.AndroidSettings = androidSettings
}
+ if err := validateOSUpdatesProfileConflict(result.Controls); err != nil {
+ multiError = multierror.Append(multiError, err)
+ }
+
return multiError
}
+// validateOSUpdatesProfileConflict rejects a config that both configures managed
+// OS updates and includes a custom configuration profile that enforces OS
+// updates. The server checks it for non-dry runs; we replicate it here against
+// the incoming payload so dry-runs fail too.
+func validateOSUpdatesProfileConflict(controls GitOpsControls) error {
+ macOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.MacOSUpdates)
+ iOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.IOSUpdates)
+ iPadOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.IPadOSUpdates)
+ if macOSConfigured || iOSConfigured || iPadOSConfigured {
+ macOSSettings, _ := controls.MacOSSettings.(fleet.MacOSSettings)
+ for _, profile := range macOSSettings.CustomSettings {
+ contains, err := profileFileContains(profile.Path, apple_mdm.DeclarationTypeSoftwareUpdate)
+ if err != nil {
+ return err
+ }
+ if contains {
+ return fmt.Errorf("controls.apple_settings.configuration_profiles[]: %s", fleet.OSUpdatesAlreadyConfiguredErrorMessage)
+ }
+ }
+ }
+
+ windowsConfigured := osUpdatesConfigured[fleet.WindowsUpdates](controls.WindowsUpdates)
+ if windowsConfigured {
+ windowsSettings, _ := controls.WindowsSettings.(fleet.WindowsSettings)
+ for _, profile := range windowsSettings.CustomSettings.Value {
+ contains, err := profileFileContains(profile.Path, syncml.FleetOSUpdateTargetLocURI)
+ if err != nil {
+ return err
+ }
+ if contains {
+ return fmt.Errorf("controls.windows_settings.configuration_profiles[]: %s", fleet.OSUpdatesAlreadyConfiguredErrorMessage)
+ }
+ }
+ }
+
+ return nil
+}
+
+// osUpdatesSettings is implemented by the OS update settings types that report
+// whether managed OS updates are configured.
+type osUpdatesSettings interface {
+ Configured() bool
+}
+
+// osUpdatesConfigured unmarshals the raw controls value (e.g. controls.macos_updates)
+// into T and reports whether it configures managed OS updates.
+func osUpdatesConfigured[T osUpdatesSettings](v any) bool {
+ if v == nil {
+ return false
+ }
+ data, err := json.Marshal(v)
+ if err != nil {
+ return false
+ }
+ var settings T
+ if err := json.Unmarshal(data, &settings); err != nil {
+ return false
+ }
+ return settings.Configured()
+}
+
+// profileFileContains reports whether the profile file at path contains needle.
+func profileFileContains(path, needle string) (bool, error) {
+ if path == "" {
+ return false, nil
+ }
+ fileBytes, err := os.ReadFile(path)
+ if err != nil {
+ return false, fmt.Errorf("failed to read profile file %s: %v", path, err)
+ }
+ return bytes.Contains(fileBytes, []byte(needle)), nil
+}
+
func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, controlsFilePath *string) []error {
if controlsTop.Path == nil {
result.Controls = controlsTop
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index 75d7df70911..13f1dffa094 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -2952,6 +2952,96 @@ func TestGitOpsGlobProfiles(t *testing.T) {
})
}
+func TestGitOpsOSUpdatesProfileConflict(t *testing.T) {
+ t.Parallel()
+
+ const appleDecl = `{"Type":"com.apple.configuration.softwareupdate.enforcement.specific","Identifier":"x","Payload":{}}`
+ const windowsProfile = `- ./Vendor/MSFT/Policy/Config/Update/x
`
+
+ t.Run("macos updates configured with software update declaration fails", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644))
+
+ config := getGlobalConfig([]string{"controls"})
+ config += `controls:
+ macos_updates:
+ minimum_version: "14.0"
+ deadline: "2024-01-01"
+ apple_settings:
+ configuration_profiles:
+ - path: su.json
+`
+ 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)
+ assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage)
+ })
+
+ t.Run("windows updates configured with software update profile fails", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "su.xml"), []byte(windowsProfile), 0o644))
+
+ config := getGlobalConfig([]string{"controls"})
+ config += `controls:
+ windows_updates:
+ deadline_days: 5
+ grace_period_days: 1
+ windows_settings:
+ configuration_profiles:
+ - path: su.xml
+`
+ 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)
+ assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage)
+ })
+
+ t.Run("software update declaration without configured os updates is allowed", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644))
+
+ config := getGlobalConfig([]string{"controls"})
+ config += `controls:
+ apple_settings:
+ configuration_profiles:
+ - path: su.json
+`
+ yamlPath := filepath.Join(dir, "gitops.yml")
+ require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
+
+ _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
+ require.NoError(t, err)
+ })
+
+ t.Run("macos updates configured with non-conflicting profile is allowed", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.mobileconfig"), []byte(""), 0o644))
+
+ config := getGlobalConfig([]string{"controls"})
+ config += `controls:
+ macos_updates:
+ minimum_version: "14.0"
+ deadline: "2024-01-01"
+ apple_settings:
+ configuration_profiles:
+ - path: plain.mobileconfig
+`
+ yamlPath := filepath.Join(dir, "gitops.yml")
+ require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
+
+ _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
+ require.NoError(t, err)
+ })
+}
+
func TestUnknownKeyDetection(t *testing.T) {
t.Parallel()