diff --git a/changes/13643-fleetctl-gitops b/changes/13643-fleetctl-gitops
new file mode 100644
index 000000000..be7855dec
--- /dev/null
+++ b/changes/13643-fleetctl-gitops
@@ -0,0 +1,2 @@
+Added fleetctl gitops command:
+- Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.
diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go
index 939fe0ec8..d44a5762b 100644
--- a/cmd/fleetctl/apply.go
+++ b/cmd/fleetctl/apply.go
@@ -77,7 +77,7 @@ func applyCommand() *cli.Command {
opts.TeamForPolicies = policiesTeamName
}
baseDir := filepath.Dir(flFilename)
- err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
+ _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
if err != nil {
return err
}
diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go
index 5b6b9682d..3c7e99b1b 100644
--- a/cmd/fleetctl/apply_test.go
+++ b/cmd/fleetctl/apply_test.go
@@ -265,7 +265,7 @@ spec:
},
}
- require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
// enroll secret not provided, so left unchanged
assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)])
assert.False(t, ds.ApplyEnrollSecretsFuncInvoked)
@@ -379,7 +379,7 @@ spec:
},
}
- require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
// agent options still cleared
assert.Nil(t, teamsByName["team1"].Config.AgentOptions)
// macos settings and updates are now cleared.
@@ -869,12 +869,12 @@ func TestApplyPolicies(t *testing.T) {
func TestApplyPoliciesValidation(t *testing.T) {
// Team Policy Spec
filename := writeTmpYml(t, duplicateTeamPolicySpec)
- errorMsg := `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
+ errorMsg := `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
// Global Policy Spec
filename = writeTmpYml(t, duplicateGlobalPolicySpec)
- errorMsg = `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
+ errorMsg = `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
}
@@ -1168,10 +1168,10 @@ spec:
`, mobileConfigPath))
// first apply with dry-run
- require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
- require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions))
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
@@ -1204,10 +1204,10 @@ spec:
`, emptySetupAsst))
// first apply with dry-run
- require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
- require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked)
require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked)
require.True(t, ds.NewJobFuncInvoked)
@@ -1243,10 +1243,10 @@ spec:
`, bootstrapURL))
// first apply with dry-run
- require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
- require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
// all left untouched, only bootstrap package added
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go
index 6bfa82820..b256bc4fa 100644
--- a/cmd/fleetctl/fleetctl.go
+++ b/cmd/fleetctl/fleetctl.go
@@ -108,6 +108,7 @@ func createApp(
mdmCommand(),
upgradePacksCommand(),
runScriptCommand(),
+ gitopsCommand(),
}
return app
}
diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go
index ee62c50be..5f31b5020 100644
--- a/cmd/fleetctl/get_test.go
+++ b/cmd/fleetctl/get_test.go
@@ -2214,7 +2214,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
yamlFilePath := writeTmpYml(t, actualYaml)
- require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath}))
+ assert.Contains(t, runAppForTest(t, []string{"apply", "-f", yamlFilePath}), "[+] applied 2 teams\n")
}
func TestGetMDMCommandResults(t *testing.T) {
diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go
new file mode 100644
index 000000000..20775c1ed
--- /dev/null
+++ b/cmd/fleetctl/gitops.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "github.com/fleetdm/fleet/v4/pkg/spec"
+ "github.com/urfave/cli/v2"
+ "os"
+ "path/filepath"
+)
+
+func gitopsCommand() *cli.Command {
+ var (
+ flFilename string
+ flDryRun bool
+ )
+ return &cli.Command{
+ Name: "gitops",
+ Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.",
+ UsageText: `fleetctl gitops [options]`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "f",
+ EnvVars: []string{"FILENAME"},
+ Value: "",
+ Destination: &flFilename,
+ Usage: "The file with the GitOps configuration",
+ },
+ &cli.BoolFlag{
+ Name: "dry-run",
+ EnvVars: []string{"DRY_RUN"},
+ Destination: &flDryRun,
+ Usage: "Do not apply the file, just validate it",
+ },
+ configFlag(),
+ contextFlag(),
+ debugFlag(),
+ },
+ Action: func(c *cli.Context) error {
+ if flFilename == "" {
+ return errors.New("-f must be specified")
+ }
+ b, err := os.ReadFile(flFilename)
+ if err != nil {
+ return err
+ }
+ fleetClient, err := clientFromCLI(c)
+ if err != nil {
+ return err
+ }
+ baseDir := filepath.Dir(flFilename)
+ config, err := spec.GitOpsFromBytes(b, baseDir)
+ if err != nil {
+ return err
+ }
+ logf := func(format string, a ...interface{}) {
+ _, _ = fmt.Fprintf(c.App.Writer, format, a...)
+ }
+ err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun)
+ if err != nil {
+ return err
+ }
+ if flDryRun {
+ _, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n")
+ } else {
+ _, _ = fmt.Fprintf(c.App.Writer, "[!] gitops succeeded\n")
+ }
+ return nil
+ },
+ }
+}
diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go
new file mode 100644
index 000000000..901006198
--- /dev/null
+++ b/cmd/fleetctl/gitops_test.go
@@ -0,0 +1,516 @@
+package main
+
+import (
+ "context"
+ "github.com/fleetdm/fleet/v4/server/config"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ nanomdm_mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm"
+ "github.com/fleetdm/fleet/v4/server/service"
+ "github.com/micromdm/nanodep/tokenpki"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "os"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+const teamName = "Team Test"
+
+func TestBasicGlobalGitOps(t *testing.T) {
+ _, ds := runServerWithMockedDS(t)
+
+ ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
+ ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ return nil
+ }
+ ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
+ ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
+
+ // Mock appConfig
+ savedAppConfig := &fleet.AppConfig{}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
+ savedAppConfig = config
+ return nil
+ }
+ var enrolledSecrets []*fleet.EnrollSecret
+ ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
+ enrolledSecrets = secrets
+ return nil
+ }
+
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+
+ const (
+ fleetServerURL = "https://fleet.example.com"
+ orgName = "GitOps Test"
+ )
+ t.Setenv("FLEET_SERVER_URL", fleetServerURL)
+
+ _, err = tmpFile.WriteString(
+ `
+controls:
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: $FLEET_SERVER_URL
+ org_info:
+ contact_url: https://example.com/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: ${ORG_NAME}
+ secrets:
+`,
+ )
+ require.NoError(t, err)
+
+ // No file
+ var errWriter strings.Builder
+ _, err = runAppNoChecks([]string{"gitops", tmpFile.Name()})
+ assert.Error(t, err)
+ assert.Equal(t, err.Error(), "-f must be specified")
+
+ // Bad file
+ errWriter.Reset()
+ _, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no such file or directory")
+
+ // Empty file
+ errWriter.Reset()
+ badFile, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "errors occurred")
+
+ // DoGitOps error
+ t.Setenv("ORG_NAME", "")
+ _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "organization name must be present")
+
+ // Dry run
+ t.Setenv("ORG_NAME", orgName)
+ _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
+ assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
+
+ // Real run
+ _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
+ assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
+ assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
+ assert.Empty(t, enrolledSecrets)
+}
+
+func TestBasicTeamGitOps(t *testing.T) {
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
+ _, ds := runServerWithMockedDS(
+ t, &service.TestServerOpts{
+ License: license,
+ },
+ )
+
+ const secret = "TestSecret"
+
+ ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
+ ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ return nil
+ }
+ ds.ListTeamPoliciesFunc = func(
+ ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
+ ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
+ return nil, nil, nil
+ }
+ ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
+ team := &fleet.Team{
+ ID: 1,
+ CreatedAt: time.Now(),
+ Name: teamName,
+ }
+ ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
+ if name == teamName {
+ return team, nil
+ }
+ return nil, nil
+ }
+ ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
+ if tid == team.ID {
+ return team, nil
+ }
+ return nil, nil
+ }
+ var savedTeam *fleet.Team
+ ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
+ savedTeam = team
+ return team, nil
+ }
+
+ var enrolledSecrets []*fleet.EnrollSecret
+ ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
+ enrolledSecrets = secrets
+ return nil
+ }
+
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+
+ t.Setenv("TEST_SECRET", secret)
+
+ _, err = tmpFile.WriteString(
+ `
+controls:
+queries:
+policies:
+agent_options:
+name: ${TEST_TEAM_NAME}
+team_settings:
+ secrets: [{"secret":"${TEST_SECRET}"}]
+`,
+ )
+ require.NoError(t, err)
+
+ // DoGitOps error
+ t.Setenv("TEST_TEAM_NAME", "")
+ _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "'name' is required")
+
+ // Dry run
+ t.Setenv("TEST_TEAM_NAME", teamName)
+ _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
+ assert.Nil(t, savedTeam)
+
+ // Real run
+ _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
+ require.NotNil(t, savedTeam)
+ assert.Equal(t, teamName, savedTeam.Name)
+ require.Len(t, enrolledSecrets, 1)
+ assert.Equal(t, secret, enrolledSecrets[0].Secret)
+}
+
+func TestFullGlobalGitOps(t *testing.T) {
+ // mdm test configuration must be set so that activating windows MDM works.
+ testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
+ require.NoError(t, err)
+ testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
+ testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
+ fleetCfg := config.TestConfig()
+ config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
+
+ // License is not needed because we are not using any premium features in our config.
+ _, ds := runServerWithMockedDS(
+ t, &service.TestServerOpts{
+ MDMStorage: new(nanomdm_mock.Storage),
+ MDMPusher: mockPusher{},
+ FleetConfig: &fleetCfg,
+ },
+ )
+
+ var appliedScripts []*fleet.Script
+ ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
+ appliedScripts = scripts
+ return nil
+ }
+ ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ return nil
+ }
+ var appliedMacProfiles []*fleet.MDMAppleConfigProfile
+ var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
+ ds.BatchSetMDMProfilesFunc = func(
+ ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
+ ) error {
+ appliedMacProfiles = macProfiles
+ appliedWinProfiles = winProfiles
+ return nil
+ }
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
+ return nil
+ }
+ ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
+ return job, nil
+ }
+
+ // Policies
+ policy := fleet.Policy{}
+ policy.ID = 1
+ policy.Name = "Policy to delete"
+ policyDeleted := false
+ ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
+ return []*fleet.Policy{&policy}, nil
+ }
+ ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
+ if slices.Contains(ids, 1) {
+ return map[uint]*fleet.Policy{1: &policy}, nil
+ }
+ return nil, nil
+ }
+ ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) {
+ policyDeleted = true
+ assert.Equal(t, []uint{policy.ID}, ids)
+ return ids, nil
+ }
+ var appliedPolicySpecs []*fleet.PolicySpec
+ ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
+ appliedPolicySpecs = specs
+ return nil
+ }
+
+ // Queries
+ query := fleet.Query{}
+ query.ID = 1
+ query.Name = "Query to delete"
+ queryDeleted := false
+ ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
+ return []*fleet.Query{&query}, nil
+ }
+ ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
+ queryDeleted = true
+ assert.Equal(t, []uint{query.ID}, ids)
+ return 1, nil
+ }
+ ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
+ if id == query.ID {
+ return &query, nil
+ }
+ return nil, nil
+ }
+ var appliedQueries []*fleet.Query
+ ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
+ return nil, ¬FoundError{}
+ }
+ ds.ApplyQueriesFunc = func(
+ ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
+ ) error {
+ appliedQueries = queries
+ return nil
+ }
+
+ // Mock appConfig
+ savedAppConfig := &fleet.AppConfig{}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
+ }
+ ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
+ savedAppConfig = config
+ return nil
+ }
+ var enrolledSecrets []*fleet.EnrollSecret
+ ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
+ enrolledSecrets = secrets
+ return nil
+ }
+
+ const (
+ fleetServerURL = "https://fleet.example.com"
+ orgName = "GitOps Test"
+ )
+ t.Setenv("FLEET_SERVER_URL", fleetServerURL)
+ t.Setenv("ORG_NAME", orgName)
+
+ // Dry run
+ file := "./testdata/gitops/global_config_no_paths.yml"
+ _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
+ assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
+ assert.Len(t, enrolledSecrets, 0)
+ assert.Len(t, appliedPolicySpecs, 0)
+ assert.Len(t, appliedQueries, 0)
+ assert.Len(t, appliedScripts, 0)
+ assert.Len(t, appliedMacProfiles, 0)
+ assert.Len(t, appliedWinProfiles, 0)
+
+ // Real run
+ _ = runAppForTest(t, []string{"gitops", "-f", file})
+ assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
+ assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
+ assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration")
+ assert.Len(t, enrolledSecrets, 2)
+ assert.True(t, policyDeleted)
+ assert.Len(t, appliedPolicySpecs, 5)
+ assert.True(t, queryDeleted)
+ assert.Len(t, appliedQueries, 3)
+ assert.Len(t, appliedScripts, 1)
+ assert.Len(t, appliedMacProfiles, 1)
+ assert.Len(t, appliedWinProfiles, 1)
+}
+
+func TestFullTeamGitOps(t *testing.T) {
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
+
+ // mdm test configuration must be set so that activating windows MDM works.
+ testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
+ require.NoError(t, err)
+ testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
+ testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
+ fleetCfg := config.TestConfig()
+ config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
+
+ // License is not needed because we are not using any premium features in our config.
+ _, ds := runServerWithMockedDS(
+ t, &service.TestServerOpts{
+ License: license,
+ MDMStorage: new(nanomdm_mock.Storage),
+ MDMPusher: mockPusher{},
+ FleetConfig: &fleetCfg,
+ },
+ )
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{
+ MDM: fleet.MDM{
+ EnabledAndConfigured: true,
+ WindowsEnabledAndConfigured: true,
+ },
+ }, nil
+ }
+
+ var appliedScripts []*fleet.Script
+ ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
+ appliedScripts = scripts
+ return nil
+ }
+ ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ return nil
+ }
+ var appliedMacProfiles []*fleet.MDMAppleConfigProfile
+ var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
+ ds.BatchSetMDMProfilesFunc = func(
+ ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
+ ) error {
+ appliedMacProfiles = macProfiles
+ appliedWinProfiles = winProfiles
+ return nil
+ }
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
+ return nil
+ }
+ ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
+ return job, nil
+ }
+
+ // Team
+ team := &fleet.Team{
+ ID: 1,
+ CreatedAt: time.Now(),
+ Name: teamName,
+ }
+ ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
+ if name == teamName {
+ return team, nil
+ }
+ return nil, nil
+ }
+ ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
+ if tid == team.ID {
+ return team, nil
+ }
+ return nil, nil
+ }
+ var savedTeam *fleet.Team
+ ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
+ savedTeam = team
+ return team, nil
+ }
+
+ // Policies
+ policy := fleet.Policy{}
+ policy.ID = 1
+ policy.Name = "Policy to delete"
+ policy.TeamID = &team.ID
+ policyDeleted := false
+ ds.ListTeamPoliciesFunc = func(
+ ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
+ ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
+ return []*fleet.Policy{&policy}, nil, nil
+ }
+ ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
+ if slices.Contains(ids, 1) {
+ return map[uint]*fleet.Policy{1: &policy}, nil
+ }
+ return nil, nil
+ }
+ ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, IDs []uint) ([]uint, error) {
+ policyDeleted = true
+ assert.Equal(t, []uint{policy.ID}, IDs)
+ return []uint{policy.ID}, nil
+ }
+ var appliedPolicySpecs []*fleet.PolicySpec
+ ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
+ appliedPolicySpecs = specs
+ return nil
+ }
+
+ // Queries
+ query := fleet.Query{}
+ query.ID = 1
+ query.TeamID = &team.ID
+ query.Name = "Query to delete"
+ queryDeleted := false
+ ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
+ return []*fleet.Query{&query}, nil
+ }
+ ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
+ queryDeleted = true
+ assert.Equal(t, []uint{query.ID}, ids)
+ return 1, nil
+ }
+ ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
+ if id == query.ID {
+ return &query, nil
+ }
+ return nil, nil
+ }
+ var appliedQueries []*fleet.Query
+ ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
+ return nil, ¬FoundError{}
+ }
+ ds.ApplyQueriesFunc = func(
+ ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
+ ) error {
+ appliedQueries = queries
+ return nil
+ }
+
+ var enrolledSecrets []*fleet.EnrollSecret
+ ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
+ enrolledSecrets = secrets
+ return nil
+ }
+
+ t.Setenv("TEST_TEAM_NAME", teamName)
+
+ // Dry run
+ file := "./testdata/gitops/team_config_no_paths.yml"
+ _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
+ assert.Nil(t, savedTeam)
+ assert.Len(t, enrolledSecrets, 0)
+ assert.Len(t, appliedPolicySpecs, 0)
+ assert.Len(t, appliedQueries, 0)
+ assert.Len(t, appliedScripts, 0)
+ assert.Len(t, appliedMacProfiles, 0)
+ assert.Len(t, appliedWinProfiles, 0)
+
+ // Real run
+ _ = runAppForTest(t, []string{"gitops", "-f", file})
+ require.NotNil(t, savedTeam)
+ assert.Equal(t, teamName, savedTeam.Name)
+ assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration")
+ assert.True(t, savedTeam.Config.Features.EnableHostUsers)
+ assert.Equal(t, 30, savedTeam.Config.HostExpirySettings.HostExpiryWindow)
+ assert.True(t, savedTeam.Config.MDM.EnableDiskEncryption)
+ assert.Len(t, enrolledSecrets, 2)
+ assert.True(t, policyDeleted)
+ assert.Len(t, appliedPolicySpecs, 5)
+ assert.True(t, queryDeleted)
+ assert.Len(t, appliedQueries, 3)
+ assert.Len(t, appliedScripts, 1)
+ assert.Len(t, appliedMacProfiles, 1)
+ assert.Len(t, appliedWinProfiles, 1)
+}
diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go
index 77f20b9cb..5b61b1d62 100644
--- a/cmd/fleetctl/preview.go
+++ b/cmd/fleetctl/preview.go
@@ -342,7 +342,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st
}
// this only applies standard queries, the base directory is not used,
// so pass in the current working directory.
- err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
+ _, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
if err != nil {
return err
}
diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml
new file mode 100644
index 000000000..7d0b81e96
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml
@@ -0,0 +1,175 @@
+# Test config
+controls: # Controls added to "No team"
+ macos_settings:
+ custom_settings:
+ - path: ./lib/macos-password.mobileconfig
+ windows_settings:
+ custom_settings:
+ - path: ./lib/windows-screenlock.xml
+ scripts:
+ - path: ./lib/collect-fleetd-logs.sh
+ 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_updates:
+ deadline_days: null
+ grace_period_days: null
+queries:
+ - name: Scheduled query stats
+ description: Collect osquery performance stats directly from osquery
+ query: SELECT *,
+ (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
+ FROM osquery_schedule;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: false
+ logging: snapshot
+ - name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - name: 😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: Passing policy
+ platform: linux,windows,darwin,chrome
+ description: This policy should always pass.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1;
+ - name: No root logins (macOS, Linux)
+ platform: linux,darwin
+ query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
+ WHERE username = "root"
+ AND time > (( SELECT unix_time FROM time ) - 3600 ))
+ critical: true
+ - name: 🔥 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+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:
+ debug_host_ids:
+ - 10728
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: $FLEET_SERVER_URL
+ org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: $ORG_NAME
+ smtp_settings:
+ authentication_method: authmethod_plain
+ authentication_type: authtype_username_password
+ configured: false
+ domain: ""
+ enable_smtp: false
+ enable_ssl_tls: true
+ enable_start_tls: true
+ password: ""
+ port: 587
+ sender_address: ""
+ server: ""
+ user_name: ""
+ verify_ssl_certs: true
+ sso_settings:
+ enable_jit_provisioning: false
+ enable_jit_role_sync: false
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: https://saml.example.com/entityid
+ idp_image_url: ""
+ idp_name: MockSAML
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: https://mocksaml.com/api/saml/metadata
+ integrations:
+ jira: []
+ zendesk: []
+ mdm:
+ apple_bm_default_team: ""
+ end_user_authentication:
+ entity_id: ""
+ idp_name: ""
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: ""
+ webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://host.docker.internal:8080/bozo
+ enable_failing_policies_webhook: false
+ host_batch_size: 0
+ policy_ids: []
+ host_status_webhook:
+ days_count: 0
+ destination_url: ""
+ enable_host_status_webhook: false
+ host_percentage: 0
+ interval: 24h0m0s
+ vulnerabilities_webhook:
+ destination_url: ""
+ enable_vulnerabilities_webhook: false
+ host_batch_size: 0
+ fleet_desktop: # Applies to Fleet Premium only
+ transparency_url: https://fleetdm.com/transparency
+ host_expiry_settings: # Applies to all teams
+ host_expiry_enabled: false
+ features: # Features added to all teams
+ enable_host_users: true
+ enable_software_inventory: true
+ vulnerability_settings:
+ databases_path: ""
+ secrets: # These secrets are used to enroll hosts to the "All teams" team
+ - secret: SampleSecret123
+ - secret: ABC
diff --git a/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh b/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh
new file mode 100644
index 000000000..887af2ace
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh
@@ -0,0 +1,7 @@
+cp /var/log/orbit/orbit.stderr.log ~/Library/Logs/Fleet/fleet-desktop.log /Users/Shared
+
+echo "Successfully copied fleetd logs to the /Users/Shared folder."
+
+echo "To retrieve logs, ask the end user to open Finder and in the menu bar select Go > Go to Folder."
+
+echo "Then, ask the end user to type in /Users/Shared, press Return, and locate orbit.stderr.log (Orbit logs) and fleet-desktop.log (Fleet Desktop logs) files."
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig b/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig
new file mode 100644
index 000000000..2fe2f717d
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig
@@ -0,0 +1,55 @@
+
+
+
+
+ PayloadContent
+
+
+ PayloadDescription
+ Configures Passcode settings
+ PayloadDisplayName
+ Passcode
+ PayloadIdentifier
+ com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E.com.apple.mobiledevice.passwordpolicy.231DFC90-D5A7-41B8-9246-564056048AC5
+ PayloadOrganization
+
+ PayloadType
+ com.apple.mobiledevice.passwordpolicy
+ PayloadUUID
+ 231DFC90-D5A7-41B8-9246-564056048AC5
+ PayloadVersion
+ 1
+ allowSimple
+
+ forcePIN
+
+ maxFailedAttempts
+ 11
+ maxGracePeriod
+ 1
+ maxInactivity
+ 15
+ minLength
+ 10
+ requireAlphanumeric
+
+
+
+ PayloadDescription
+ Configures our Macs to require passwords that are 10 character long
+ PayloadDisplayName
+ Password policy - require 10 characters
+ PayloadIdentifier
+ com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E
+ PayloadOrganization
+ FleetDM
+ PayloadScope
+ System
+ PayloadType
+ Configuration
+ PayloadUUID
+ F7CF282E-D91B-44E9-922F-A719634F9C8E
+ PayloadVersion
+ 1
+
+
\ No newline at end of file
diff --git a/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml b/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml
new file mode 100644
index 000000000..3d7d52ded
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml
@@ -0,0 +1,48 @@
+
+
+ -
+
+ int
+
+
+ ./Device/Vendor/MSFT/Policy/Config/DeviceLock/DevicePasswordEnabled
+
+ 0
+
+
+
+
+ -
+
+ int
+
+
+ ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MaxInactivityTimeDeviceLock
+
+ 15
+
+
+
+
+ -
+
+ int
+
+
+ ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordLength
+
+ 10
+
+
+
+
+ -
+
+ int
+
+
+ ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordComplexCharacters
+
+ 2
+
+
diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
new file mode 100644
index 000000000..6a4fbdf2d
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
@@ -0,0 +1,111 @@
+name: "${TEST_TEAM_NAME}"
+team_settings:
+ secrets:
+ - secret: "SampleSecret123"
+ - secret: "ABC"
+ webhook_settings:
+ failing_policies_webhook:
+ enable_failing_policies_webhook: true
+ destination_url: https://example.tines.com/webhook
+ policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
+ features:
+ enable_host_users: true
+ enable_software_inventory: true
+ host_expiry_settings:
+ host_expiry_enabled: true
+ host_expiry_window: 30
+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: /
+controls:
+ macos_settings:
+ custom_settings:
+ - path: ./lib/macos-password.mobileconfig
+ windows_settings:
+ custom_settings:
+ - path: ./lib/windows-screenlock.xml
+ scripts:
+ - path: ./lib/collect-fleetd-logs.sh
+ enable_disk_encryption: true
+ 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_updates:
+ deadline_days: null
+ grace_period_days: null
+queries:
+ - name: Scheduled query stats
+ description: Collect osquery performance stats directly from osquery
+ query: SELECT *,
+ (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
+ FROM osquery_schedule;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: false
+ logging: snapshot
+ - name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - name: 😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: Passing policy
+ platform: linux,windows,darwin,chrome
+ description: This policy should always pass.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1;
+ - name: No root logins (macOS, Linux)
+ platform: linux,darwin
+ query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
+ WHERE username = "root"
+ AND time > (( SELECT unix_time FROM time ) - 3600 ))
+ critical: true
+ - name: 🔥 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go
index 0e7fd4cda..ffb5a908e 100644
--- a/ee/server/service/teams.go
+++ b/ee/server/service/teams.go
@@ -804,24 +804,22 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
})
}
- if applyOpts.DryRun {
- return nil, nil
- }
-
idsByName := make(map[string]uint, len(details))
if len(details) > 0 {
for _, tm := range details {
idsByName[tm.Name] = tm.ID
}
- if err := svc.ds.NewActivity(
- ctx,
- authz.UserFromContext(ctx),
- fleet.ActivityTypeAppliedSpecTeam{
- Teams: details,
- },
- ); err != nil {
- return nil, ctxerr.Wrap(ctx, err, "create activity for team spec")
+ if !applyOpts.DryRun {
+ if err := svc.ds.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeAppliedSpecTeam{
+ Teams: details,
+ },
+ ); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create activity for team spec")
+ }
}
}
return idsByName, nil
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
new file mode 100644
index 000000000..adf02a352
--- /dev/null
+++ b/pkg/spec/gitops.go
@@ -0,0 +1,492 @@
+package spec
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/ghodss/yaml"
+ "github.com/hashicorp/go-multierror"
+ "os"
+ "path/filepath"
+ "slices"
+ "unicode"
+)
+
+type BaseItem struct {
+ Path *string `json:"path"`
+}
+
+type Controls struct {
+ BaseItem
+ MacOSUpdates interface{} `json:"macos_updates"`
+ MacOSSettings interface{} `json:"macos_settings"`
+ MacOSSetup interface{} `json:"macos_setup"`
+ MacOSMigration interface{} `json:"macos_migration"`
+
+ WindowsUpdates interface{} `json:"windows_updates"`
+ WindowsSettings interface{} `json:"windows_settings"`
+ WindowsEnabledAndConfigured interface{} `json:"windows_enabled_and_configured"`
+
+ EnableDiskEncryption interface{} `json:"enable_disk_encryption"`
+
+ Scripts []BaseItem `json:"scripts"`
+}
+
+type Policy struct {
+ BaseItem
+ fleet.PolicySpec
+}
+
+type Query struct {
+ BaseItem
+ fleet.QuerySpec
+}
+
+type GitOps struct {
+ TeamID *uint
+ TeamName *string
+ TeamSettings map[string]interface{}
+ OrgSettings map[string]interface{}
+ AgentOptions *json.RawMessage
+ Controls Controls
+ Policies []*fleet.PolicySpec
+ Queries []*fleet.QuerySpec
+}
+
+// GitOpsFromBytes parses a GitOps yaml file.
+func GitOpsFromBytes(b []byte, baseDir string) (*GitOps, error) {
+ var top map[string]json.RawMessage
+ b = []byte(os.ExpandEnv(string(b))) // replace $var and ${var} with env values
+ if err := yaml.Unmarshal(b, &top); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal file %w: \n", err)
+ }
+
+ var multiError *multierror.Error
+ result := &GitOps{}
+
+ topKeys := []string{"name", "team_settings", "org_settings", "agent_options", "controls", "policies", "queries"}
+ for k := range top {
+ if !slices.Contains(topKeys, k) {
+ multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k))
+ }
+ }
+
+ // Figure out if this is an org or team settings file
+ teamRaw, teamOk := top["name"]
+ teamSettingsRaw, teamSettingsOk := top["team_settings"]
+ orgSettingsRaw, orgOk := top["org_settings"]
+ if orgOk {
+ if teamOk || teamSettingsOk {
+ multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name' or 'team_settings'"))
+ } else {
+ multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
+ }
+ } else if teamOk && teamSettingsOk {
+ multiError = parseName(teamRaw, result, multiError)
+ multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
+ } else {
+ multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
+ }
+
+ // Validate the required top level options
+ multiError = parseControls(top, result, baseDir, multiError)
+ multiError = parseAgentOptions(top, result, baseDir, multiError)
+ multiError = parsePolicies(top, result, baseDir, multiError)
+ multiError = parseQueries(top, result, baseDir, multiError)
+
+ return result, multiError.ErrorOrNil()
+}
+
+func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error) *multierror.Error {
+ if err := json.Unmarshal(raw, &result.TeamName); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal name: %v", err))
+ }
+ if result.TeamName == nil || *result.TeamName == "" {
+ return multierror.Append(multiError, errors.New("team 'name' is required"))
+ }
+ if !isASCII(*result.TeamName) {
+ multiError = multierror.Append(multiError, fmt.Errorf("team name must be in ASCII: %s", *result.TeamName))
+ }
+ return multiError
+}
+
+func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ var orgSettingsTop BaseItem
+ if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal org_settings: %v", err))
+ }
+ noError := true
+ if orgSettingsTop.Path != nil {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path))
+ if err != nil {
+ noError = false
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to read org settings file %s: %v", *orgSettingsTop.Path, err))
+ } else {
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathOrgSettings BaseItem
+ if err := yaml.Unmarshal(fileBytes, &pathOrgSettings); err != nil {
+ noError = false
+ multiError = multierror.Append(
+ multiError, fmt.Errorf("failed to unmarshal org settings file %s: %v", *orgSettingsTop.Path, err),
+ )
+ } else {
+ if pathOrgSettings.Path != nil {
+ noError = false
+ multiError = multierror.Append(
+ multiError,
+ fmt.Errorf("nested paths are not supported: %s in %s", *pathOrgSettings.Path, *orgSettingsTop.Path),
+ )
+ } else {
+ raw = fileBytes
+ }
+ }
+ }
+ }
+ if noError {
+ if err := yaml.Unmarshal(raw, &result.OrgSettings); err != nil {
+ // This error is currently unreachable because we know the file is valid YAML when we checked for nested path
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal org settings: %v", err))
+ } else {
+ multiError = parseSecrets(result, multiError)
+ }
+ // TODO: Validate that integrations.(jira|zendesk)[].api_token is not empty or fleet.MaskedPassword
+ }
+ return multiError
+}
+
+func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ var teamSettingsTop BaseItem
+ if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal team_settings: %v", err))
+ }
+ noError := true
+ if teamSettingsTop.Path != nil {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *teamSettingsTop.Path))
+ if err != nil {
+ noError = false
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to read team settings file %s: %v", *teamSettingsTop.Path, err))
+ } else {
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathTeamSettings BaseItem
+ if err := yaml.Unmarshal(fileBytes, &pathTeamSettings); err != nil {
+ noError = false
+ multiError = multierror.Append(
+ multiError, fmt.Errorf("failed to unmarshal team settings file %s: %v", *teamSettingsTop.Path, err),
+ )
+ } else {
+ if pathTeamSettings.Path != nil {
+ noError = false
+ multiError = multierror.Append(
+ multiError,
+ fmt.Errorf("nested paths are not supported: %s in %s", *pathTeamSettings.Path, *teamSettingsTop.Path),
+ )
+ } else {
+ raw = fileBytes
+ }
+ }
+ }
+ }
+ if noError {
+ if err := yaml.Unmarshal(raw, &result.TeamSettings); err != nil {
+ // This error is currently unreachable because we know the file is valid YAML when we checked for nested path
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal team settings: %v", err))
+ } else {
+ multiError = parseSecrets(result, multiError)
+ }
+ }
+ return multiError
+}
+
+func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Error {
+ var rawSecrets interface{}
+ var ok bool
+ if result.TeamName == nil {
+ rawSecrets, ok = result.OrgSettings["secrets"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'org_settings.secrets' is required"))
+ }
+ } else {
+ rawSecrets, ok = result.TeamSettings["secrets"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'team_settings.secrets' is required"))
+ }
+ }
+ var enrollSecrets []*fleet.EnrollSecret
+ if rawSecrets != nil {
+ secrets, ok := rawSecrets.([]interface{})
+ if !ok {
+ return multierror.Append(multiError, errors.New("'secrets' must be a list of secret items"))
+ }
+ for _, enrollSecret := range secrets {
+ var secret string
+ var secretInterface interface{}
+ secretMap, ok := enrollSecret.(map[string]interface{})
+ if ok {
+ secretInterface, ok = secretMap["secret"]
+ }
+ if ok {
+ secret, ok = secretInterface.(string)
+ }
+ if !ok || secret == "" {
+ multiError = multierror.Append(
+ multiError, errors.New("each item in 'secrets' must have a 'secret' key containing an ASCII string value"),
+ )
+ break
+ }
+ enrollSecrets = append(
+ enrollSecrets, &fleet.EnrollSecret{Secret: secret},
+ )
+ }
+ }
+ if result.TeamName == nil {
+ result.OrgSettings["secrets"] = enrollSecrets
+ } else {
+ result.TeamSettings["secrets"] = enrollSecrets
+ }
+ return multiError
+}
+
+func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ agentOptionsRaw, ok := top["agent_options"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'agent_options' is required"))
+ }
+ var agentOptionsTop BaseItem
+ if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal agent_options: %v", err))
+ } else {
+ if agentOptionsTop.Path == nil {
+ result.AgentOptions = &agentOptionsRaw
+ } else {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *agentOptionsTop.Path))
+ if err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to read agent options file %s: %v", *agentOptionsTop.Path, err))
+ }
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathAgentOptions BaseItem
+ if err := yaml.Unmarshal(fileBytes, &pathAgentOptions); err != nil {
+ return multierror.Append(
+ multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
+ )
+ }
+ if pathAgentOptions.Path != nil {
+ return multierror.Append(
+ multiError,
+ fmt.Errorf("nested paths are not supported: %s in %s", *pathAgentOptions.Path, *agentOptionsTop.Path),
+ )
+ }
+ var raw json.RawMessage
+ if err := yaml.Unmarshal(fileBytes, &raw); err != nil {
+ // This error is currently unreachable because we know the file is valid YAML when we checked for nested path
+ return multierror.Append(
+ multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
+ )
+ }
+ result.AgentOptions = &raw
+ }
+ }
+ return multiError
+}
+
+func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ controlsRaw, ok := top["controls"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'controls' is required"))
+ }
+ var controlsTop Controls
+ if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err))
+ }
+ if controlsTop.Path == nil {
+ result.Controls = controlsTop
+ } else {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *controlsTop.Path))
+ if err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to read controls file %s: %v", *controlsTop.Path, err))
+ }
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathControls Controls
+ if err := yaml.Unmarshal(fileBytes, &pathControls); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls file %s: %v", *controlsTop.Path, err))
+ }
+ if pathControls.Path != nil {
+ return multierror.Append(
+ multiError,
+ fmt.Errorf("nested paths are not supported: %s in %s", *pathControls.Path, *controlsTop.Path),
+ )
+ }
+ result.Controls = pathControls
+ }
+ return multiError
+}
+
+func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ policiesRaw, ok := top["policies"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'policies' key is required"))
+ }
+ var policies []Policy
+ if err := json.Unmarshal(policiesRaw, &policies); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies: %v", err))
+ }
+ for _, item := range policies {
+ item := item
+ if item.Path == nil {
+ result.Policies = append(result.Policies, &item.PolicySpec)
+ } else {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
+ if err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err))
+ continue
+ }
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathPolicies []*Policy
+ if err := yaml.Unmarshal(fileBytes, &pathPolicies); err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies file %s: %v", *item.Path, err))
+ continue
+ }
+ for _, pp := range pathPolicies {
+ pp := pp
+ if pp != nil {
+ if pp.Path != nil {
+ multiError = multierror.Append(
+ multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path),
+ )
+ } else {
+ result.Policies = append(result.Policies, &pp.PolicySpec)
+ }
+ }
+ }
+ }
+ }
+ // Make sure team name is correct, and do additional validation
+ for _, item := range result.Policies {
+ if item.Name == "" {
+ multiError = multierror.Append(multiError, errors.New("policy name is required for each policy"))
+ }
+ if item.Query == "" {
+ multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
+ }
+ if result.TeamName != nil {
+ item.Team = *result.TeamName
+ } else {
+ item.Team = ""
+ }
+ }
+ duplicates := getDuplicateNames(
+ result.Policies, func(p *fleet.PolicySpec) string {
+ return p.Name
+ },
+ )
+ if len(duplicates) > 0 {
+ multiError = multierror.Append(multiError, fmt.Errorf("duplicate policy names: %v", duplicates))
+ }
+ return multiError
+}
+
+func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
+ queriesRaw, ok := top["queries"]
+ if !ok {
+ return multierror.Append(multiError, errors.New("'queries' key is required"))
+ }
+ var queries []Query
+ if err := json.Unmarshal(queriesRaw, &queries); err != nil {
+ return multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries: %v", err))
+ }
+ for _, item := range queries {
+ item := item
+ if item.Path == nil {
+ result.Queries = append(result.Queries, &item.QuerySpec)
+ } else {
+ fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
+ if err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to read queries file %s: %v", *item.Path, err))
+ continue
+ }
+ fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
+ var pathQueries []*Query
+ if err := yaml.Unmarshal(fileBytes, &pathQueries); err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries file %s: %v", *item.Path, err))
+ continue
+ }
+ for _, pq := range pathQueries {
+ pq := pq
+ if pq != nil {
+ if pq.Path != nil {
+ multiError = multierror.Append(
+ multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path),
+ )
+ } else {
+ result.Queries = append(result.Queries, &pq.QuerySpec)
+ }
+ }
+ }
+ }
+ }
+ // Make sure team name is correct and do additional validation
+ for _, q := range result.Queries {
+ if q.Name == "" {
+ multiError = multierror.Append(multiError, errors.New("query name is required for each query"))
+ }
+ if q.Query == "" {
+ multiError = multierror.Append(multiError, errors.New("query SQL query is required for each query"))
+ }
+ // Don't use non-ASCII
+ if !isASCII(q.Name) {
+ multiError = multierror.Append(multiError, fmt.Errorf("query name must be in ASCII: %s", q.Name))
+ }
+ if result.TeamName != nil {
+ q.TeamName = *result.TeamName
+ } else {
+ q.TeamName = ""
+ }
+ }
+ duplicates := getDuplicateNames(
+ result.Queries, func(q *fleet.QuerySpec) string {
+ return q.Name
+ },
+ )
+ if len(duplicates) > 0 {
+ multiError = multierror.Append(multiError, fmt.Errorf("duplicate query names: %v", duplicates))
+ }
+ return multiError
+}
+
+func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string {
+ // We are using the allKeys map as a set here. True means the item is a duplicate.
+ allKeys := make(map[string]bool)
+ var duplicates []string
+ for _, item := range slice {
+ name := getComparableString(item)
+ if isDuplicate, exists := allKeys[name]; exists {
+ // If this name hasn't already been marked as a duplicate.
+ if !isDuplicate {
+ duplicates = append(duplicates, name)
+ }
+ allKeys[name] = true
+ } else {
+ allKeys[name] = false
+ }
+ }
+ return duplicates
+}
+
+func isASCII(s string) bool {
+ for _, c := range s {
+ if c > unicode.MaxASCII {
+ return false
+ }
+ }
+ return true
+}
+
+// resolves the paths to an absolute path relative to the baseDir, which should
+// be the path of the YAML file where the relative paths were specified. If the
+// path is already absolute, it is left untouched.
+func resolveApplyRelativePath(baseDir string, path string) string {
+ if baseDir == "" || filepath.IsAbs(path) {
+ return path
+ }
+ return filepath.Join(baseDir, path)
+}
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
new file mode 100644
index 000000000..3feea55b4
--- /dev/null
+++ b/pkg/spec/gitops_test.go
@@ -0,0 +1,649 @@
+package spec
+
+import (
+ "fmt"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+)
+
+var topLevelOptions = map[string]string{
+ "controls": "controls:",
+ "queries": "queries:",
+ "policies": "policies:",
+ "agent_options": "agent_options:",
+ "org_settings": `
+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:
+`,
+}
+
+var teamLevelOptions = map[string]string{
+ "controls": "controls:",
+ "queries": "queries:",
+ "policies": "policies:",
+ "agent_options": "agent_options:",
+ "name": "name: TeamName",
+ "team_settings": `
+team_settings:
+ secrets:
+`,
+}
+
+func TestValidGitOpsYaml(t *testing.T) {
+ t.Parallel()
+ tests := map[string]struct {
+ filePath string
+ isTeam bool
+ }{
+ "global_config_no_paths": {
+ filePath: "testdata/global_config_no_paths.yml",
+ },
+ "global_config_with_paths": {
+ filePath: "testdata/global_config.yml",
+ },
+ "team_config_no_paths": {
+ filePath: "testdata/team_config_no_paths.yml",
+ isTeam: true,
+ },
+ "team_config_with_paths": {
+ filePath: "testdata/team_config.yml",
+ isTeam: true,
+ },
+ }
+
+ for name, test := range tests {
+ test := test
+ name := name
+ t.Run(
+ name, func(t *testing.T) {
+ t.Parallel()
+ dat, err := os.ReadFile(test.filePath)
+ require.NoError(t, err)
+ gitops, err := GitOpsFromBytes(dat, "./testdata")
+ require.NoError(t, err)
+
+ if test.isTeam {
+ // Check team settings
+ assert.Equal(t, "Team1", *gitops.TeamName)
+ assert.Contains(t, gitops.TeamSettings, "webhook_settings")
+ assert.Contains(t, gitops.TeamSettings, "host_expiry_settings")
+ assert.Contains(t, gitops.TeamSettings, "features")
+ assert.Contains(t, gitops.TeamSettings, "secrets")
+ secrets, ok := gitops.TeamSettings["secrets"]
+ assert.True(t, ok, "secrets not found")
+ require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
+ assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
+ assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
+ } else {
+ // Check org settings
+ serverSettings, ok := gitops.OrgSettings["server_settings"]
+ assert.True(t, ok, "server_settings not found")
+ assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"])
+ assert.Contains(t, gitops.OrgSettings, "org_info")
+ assert.Contains(t, gitops.OrgSettings, "smtp_settings")
+ assert.Contains(t, gitops.OrgSettings, "sso_settings")
+ assert.Contains(t, gitops.OrgSettings, "integrations")
+ assert.Contains(t, gitops.OrgSettings, "mdm")
+ assert.Contains(t, gitops.OrgSettings, "webhook_settings")
+ assert.Contains(t, gitops.OrgSettings, "fleet_desktop")
+ assert.Contains(t, gitops.OrgSettings, "host_expiry_settings")
+ assert.Contains(t, gitops.OrgSettings, "features")
+ assert.Contains(t, gitops.OrgSettings, "vulnerability_settings")
+ assert.Contains(t, gitops.OrgSettings, "secrets")
+ secrets, ok := gitops.OrgSettings["secrets"]
+ assert.True(t, ok, "secrets not found")
+ require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
+ assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
+ assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
+ }
+
+ // Check controls
+ _, ok := gitops.Controls.MacOSSettings.(map[string]interface{})
+ assert.True(t, ok, "macos_settings not found")
+ _, ok = gitops.Controls.WindowsSettings.(map[string]interface{})
+ assert.True(t, ok, "windows_settings not found")
+ _, ok = gitops.Controls.EnableDiskEncryption.(bool)
+ assert.True(t, ok, "enable_disk_encryption not found")
+ _, ok = gitops.Controls.MacOSMigration.(map[string]interface{})
+ assert.True(t, ok, "macos_migration not found")
+ _, ok = gitops.Controls.MacOSSetup.(map[string]interface{})
+ assert.True(t, ok, "macos_setup not found")
+ _, ok = gitops.Controls.MacOSUpdates.(map[string]interface{})
+ assert.True(t, ok, "macos_updates not found")
+ _, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool)
+ assert.True(t, ok, "windows_enabled_and_configured not found")
+ _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
+ assert.True(t, ok, "windows_updates not found")
+
+ // Check agent options
+ assert.NotNil(t, gitops.AgentOptions)
+ assert.Contains(t, string(*gitops.AgentOptions), "distributed_denylist_duration")
+
+ // Check queries
+ require.Len(t, gitops.Queries, 3)
+ assert.Equal(t, "Scheduled query stats", gitops.Queries[0].Name)
+ assert.Equal(t, "orbit_info", gitops.Queries[1].Name)
+ assert.Equal(t, "osquery_info", gitops.Queries[2].Name)
+
+ // Check policies
+ require.Len(t, gitops.Policies, 5)
+ assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name)
+ assert.Equal(t, "Passing policy", gitops.Policies[1].Name)
+ assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name)
+ assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name)
+ assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name)
+
+ },
+ )
+ }
+}
+
+func TestDuplicatePolicyNames(t *testing.T) {
+ t.Parallel()
+ config := getGlobalConfig([]string{"policies"})
+ config += `
+policies:
+ - name: My policy
+ platform: linux
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: My policy
+ platform: windows
+ query: SELECT 1;
+`
+ _, err := GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "duplicate policy names")
+}
+
+func TestDuplicateQueryNames(t *testing.T) {
+ t.Parallel()
+ config := getGlobalConfig([]string{"queries"})
+ config += `
+queries:
+- name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+- name: orbit_info
+ query: SELECT 1;
+ interval: 300
+ platform: windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+`
+ _, err := GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "duplicate query names")
+}
+
+func TestUnicodeQueryNames(t *testing.T) {
+ t.Parallel()
+ config := getGlobalConfig([]string{"queries"})
+ config += `
+queries:
+- name: 😊 orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+`
+ _, err := GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "query name must be in ASCII")
+}
+
+func TestUnicodeTeamName(t *testing.T) {
+ t.Parallel()
+ config := getTeamConfig([]string{"name"})
+ config += `name: 😊 TeamName`
+ _, err := GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "team name must be in ASCII")
+}
+
+func TestMixingGlobalAndTeamConfig(t *testing.T) {
+ t.Parallel()
+
+ // Mixing org_settings and team name
+ config := getGlobalConfig(nil)
+ config += "name: TeamName\n"
+ _, err := GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
+
+ // Mixing org_settings and team_settings
+ config = getGlobalConfig(nil)
+ config += "team_settings:\n secrets: []\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
+
+ // Mixing org_settings and team name and team_settings
+ config = getGlobalConfig(nil)
+ config += "name: TeamName\n"
+ config += "team_settings:\n secrets: []\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
+
+}
+
+func TestInvalidGitOpsYaml(t *testing.T) {
+ t.Parallel()
+
+ // Bad YAML
+ _, err := GitOpsFromBytes([]byte("bad:\nbad"), "")
+ assert.ErrorContains(t, err, "failed to unmarshal")
+
+ for _, name := range []string{"global", "team"} {
+ t.Run(
+ name, func(t *testing.T) {
+ isTeam := name == "team"
+ getConfig := getGlobalConfig
+ if isTeam {
+ getConfig = getTeamConfig
+ }
+
+ if isTeam {
+ // Invalid top level key
+ config := getConfig(nil)
+ config += "unknown_key:\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "unknown top-level field")
+
+ // Invalid team name
+ config = getConfig([]string{"name"})
+ config += "name: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal name")
+
+ // Missing team name
+ config = getConfig([]string{"name"})
+ config += "name:\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'name' is required")
+
+ // Invalid team_settings
+ config = getConfig([]string{"team_settings"})
+ config += "team_settings:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal team_settings")
+
+ // Invalid team_settings in a separate file
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*team_settings.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"team_settings"})
+ config += fmt.Sprintf("%s:\n path: %s\n", "team_settings", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal team settings file")
+
+ // Invalid secrets 1
+ config = getConfig([]string{"team_settings"})
+ config += "team_settings:\n secrets: bad\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "must be a list of secret items")
+
+ // Invalid secrets 2
+ config = getConfig([]string{"team_settings"})
+ config += "team_settings:\n secrets: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "must have a 'secret' key")
+
+ // Missing secrets
+ config = getConfig([]string{"team_settings"})
+ config += "team_settings:\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'team_settings.secrets' is required")
+ } else {
+ // Invalid org_settings
+ config := getConfig([]string{"org_settings"})
+ config += "org_settings:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal org_settings")
+
+ // Invalid org_settings in a separate file
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*org_settings.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"org_settings"})
+ config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal org settings file")
+
+ // Invalid secrets 1
+ config = getConfig([]string{"org_settings"})
+ config += "org_settings:\n secrets: bad\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "must be a list of secret items")
+
+ // Invalid secrets 2
+ config = getConfig([]string{"org_settings"})
+ config += "org_settings:\n secrets: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "must have a 'secret' key")
+
+ // Missing secrets
+ config = getConfig([]string{"org_settings"})
+ config += "org_settings:\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "'org_settings.secrets' is required")
+ }
+
+ // Invalid agent_options
+ config := getConfig([]string{"agent_options"})
+ config += "agent_options:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal agent_options")
+
+ // Invalid agent_options in a separate file
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*agent_options.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"agent_options"})
+ config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal agent options file")
+
+ // Invalid controls
+ config = getConfig([]string{"controls"})
+ config += "controls:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal controls")
+
+ // Invalid controls in a separate file
+ tmpFile, err = os.CreateTemp(t.TempDir(), "*controls.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"controls"})
+ config += fmt.Sprintf("%s:\n path: %s\n", "controls", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal controls file")
+
+ // Invalid policies
+ config = getConfig([]string{"policies"})
+ config += "policies:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal policies")
+
+ // Invalid policies in a separate file
+ tmpFile, err = os.CreateTemp(t.TempDir(), "*policies.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"policies"})
+ config += fmt.Sprintf("%s:\n - path: %s\n", "policies", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal policies file")
+
+ // Policy name missing
+ config = getConfig([]string{"policies"})
+ config += "policies:\n - query: SELECT 1;\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "name is required")
+
+ // Policy query missing
+ config = getConfig([]string{"policies"})
+ config += "policies:\n - name: Test Policy\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "query is required")
+
+ // Invalid queries
+ config = getConfig([]string{"queries"})
+ config += "queries:\n path: [2]\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal queries")
+
+ // Invalid policies in a separate file
+ tmpFile, err = os.CreateTemp(t.TempDir(), "*queries.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString("[2]")
+ require.NoError(t, err)
+ config = getConfig([]string{"queries"})
+ config += fmt.Sprintf("%s:\n - path: %s\n", "queries", tmpFile.Name())
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal queries file")
+
+ // Query name missing
+ config = getConfig([]string{"queries"})
+ config += "queries:\n - query: SELECT 1;\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "name is required")
+
+ // Query SQL query missing
+ config = getConfig([]string{"queries"})
+ config += "queries:\n - name: Test Query\n"
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "query is required")
+ },
+ )
+ }
+}
+
+func TestTopLevelGitOpsValidation(t *testing.T) {
+ t.Parallel()
+ tests := map[string]struct {
+ optsToExclude []string
+ shouldPass bool
+ isTeam bool
+ }{
+ "all_present_global": {
+ optsToExclude: []string{},
+ shouldPass: true,
+ },
+ "all_present_team": {
+ optsToExclude: []string{},
+ shouldPass: true,
+ isTeam: true,
+ },
+ "missing_all": {
+ optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"},
+ },
+ "missing_controls": {
+ optsToExclude: []string{"controls"},
+ },
+ "missing_queries": {
+ optsToExclude: []string{"queries"},
+ },
+ "missing_policies": {
+ optsToExclude: []string{"policies"},
+ },
+ "missing_agent_options": {
+ optsToExclude: []string{"agent_options"},
+ },
+ "missing_org_settings": {
+ optsToExclude: []string{"org_settings"},
+ },
+ "missing_name": {
+ optsToExclude: []string{"name"},
+ isTeam: true,
+ },
+ "missing_team_settings": {
+ optsToExclude: []string{"team_settings"},
+ isTeam: true,
+ },
+ }
+ for name, test := range tests {
+ t.Run(
+ name, func(t *testing.T) {
+ var config string
+ if test.isTeam {
+ config = getTeamConfig(test.optsToExclude)
+ } else {
+ config = getGlobalConfig(test.optsToExclude)
+ }
+ _, err := GitOpsFromBytes([]byte(config), "")
+ if test.shouldPass {
+ assert.NoError(t, err)
+ } else {
+ assert.ErrorContains(t, err, "is required")
+ }
+ },
+ )
+ }
+}
+
+func TestGitOpsNullArrays(t *testing.T) {
+ t.Parallel()
+
+ config := getGlobalConfig([]string{"queries", "policies"})
+ config += "queries: null\npolicies: ~\n"
+ gitops, err := GitOpsFromBytes([]byte(config), "")
+ assert.NoError(t, err)
+ assert.Nil(t, gitops.Queries)
+ assert.Nil(t, gitops.Policies)
+}
+
+func TestGitOpsPaths(t *testing.T) {
+ t.Parallel()
+ tests := map[string]struct {
+ isArray bool
+ isTeam bool
+ goodConfig string
+ }{
+ "org_settings": {
+ isArray: false,
+ goodConfig: "secrets: []\n",
+ },
+ "team_settings": {
+ isArray: false,
+ isTeam: true,
+ goodConfig: "secrets: []\n",
+ },
+ "controls": {
+ isArray: false,
+ goodConfig: "windows_enabled_and_configured: true\n",
+ },
+ "queries": {
+ isArray: true,
+ goodConfig: "[]",
+ },
+ "policies": {
+ isArray: true,
+ goodConfig: "[]",
+ },
+ "agent_options": {
+ isArray: false,
+ goodConfig: "name: value\n",
+ },
+ }
+
+ for name, test := range tests {
+ test := test
+ name := name
+ t.Run(
+ name, func(t *testing.T) {
+ t.Parallel()
+
+ getConfig := getGlobalConfig
+ if test.isTeam {
+ getConfig = getTeamConfig
+ }
+
+ // Test an absolute top level path
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*good.yml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString(test.goodConfig)
+ require.NoError(t, err)
+ config := getConfig([]string{name})
+ if test.isArray {
+ config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFile.Name())
+ } else {
+ config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFile.Name())
+ }
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.NoError(t, err)
+
+ // Test a relative top level path
+ config = getConfig([]string{name})
+ dir, file := filepath.Split(tmpFile.Name())
+ if test.isArray {
+ config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
+ } else {
+ config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
+ }
+ _, err = GitOpsFromBytes([]byte(config), dir)
+ assert.NoError(t, err)
+
+ // Test a bad path
+ config = getConfig([]string{name})
+ if test.isArray {
+ config += fmt.Sprintf("%s:\n - path: ./%s\n", name, "doesNotExist.yml")
+ } else {
+ config += fmt.Sprintf("%s:\n path: ./%s\n", name, "doesNotExist.yml")
+ }
+ _, err = GitOpsFromBytes([]byte(config), dir)
+ assert.ErrorContains(t, err, "no such file or directory")
+
+ // Test a bad file -- cannot be unmarshalled
+ tmpFileBad, err := os.CreateTemp(t.TempDir(), "*invalid.yml")
+ require.NoError(t, err)
+ _, err = tmpFileBad.WriteString("bad:\nbad")
+ require.NoError(t, err)
+ config = getConfig([]string{name})
+ if test.isArray {
+ config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFileBad.Name())
+ } else {
+ config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFileBad.Name())
+ }
+ _, err = GitOpsFromBytes([]byte(config), "")
+ assert.ErrorContains(t, err, "failed to unmarshal")
+
+ // Test a nested path -- bad
+ tmpFileBad, err = os.CreateTemp(t.TempDir(), "*bad.yml")
+ require.NoError(t, err)
+ if test.isArray {
+ _, err = tmpFileBad.WriteString(fmt.Sprintf("- path: %s\n", tmpFile.Name()))
+ } else {
+ _, err = tmpFileBad.WriteString(fmt.Sprintf("path: %s\n", tmpFile.Name()))
+ }
+ require.NoError(t, err)
+ config = getConfig([]string{name})
+ dir, file = filepath.Split(tmpFileBad.Name())
+ if test.isArray {
+ config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
+ } else {
+ config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
+ }
+ _, err = GitOpsFromBytes([]byte(config), dir)
+ assert.ErrorContains(t, err, "nested paths are not supported")
+ },
+ )
+ }
+}
+
+func getGlobalConfig(optsToExclude []string) string {
+ return getBaseConfig(topLevelOptions, optsToExclude)
+}
+
+func getTeamConfig(optsToExclude []string) string {
+ return getBaseConfig(teamLevelOptions, optsToExclude)
+}
+
+func getBaseConfig(options map[string]string, optsToExclude []string) string {
+ var config string
+ for key, value := range options {
+ if !slices.Contains(optsToExclude, key) {
+ config += value + "\n"
+ }
+ }
+ return config
+}
diff --git a/pkg/spec/testdata/agent-options.yml b/pkg/spec/testdata/agent-options.yml
new file mode 100644
index 000000000..b2d24ce03
--- /dev/null
+++ b/pkg/spec/testdata/agent-options.yml
@@ -0,0 +1,14 @@
+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: /
diff --git a/pkg/spec/testdata/controls.yml b/pkg/spec/testdata/controls.yml
new file mode 100644
index 000000000..2adff7403
--- /dev/null
+++ b/pkg/spec/testdata/controls.yml
@@ -0,0 +1,24 @@
+macos_settings:
+ custom_settings:
+ - path: ./lib/macos-password.mobileconfig
+windows_settings:
+ custom_settings:
+ - path: ./lib/windows-screenlock.xml
+scripts:
+ - path: ./lib/collect-fleetd-logs.sh
+enable_disk_encryption: true
+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_updates:
+ deadline_days: null
+ grace_period_days: null
diff --git a/pkg/spec/testdata/empty.yml b/pkg/spec/testdata/empty.yml
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/spec/testdata/global_config.yml b/pkg/spec/testdata/global_config.yml
new file mode 100644
index 000000000..0f9f52486
--- /dev/null
+++ b/pkg/spec/testdata/global_config.yml
@@ -0,0 +1,27 @@
+# Test config
+controls: # Controls added to "No team"
+ path: ./controls.yml
+queries:
+ - path: ./top.queries.yml
+ - path: ./empty.yml
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - path: ./top.policies.yml
+ - path: ./top.policies2.yml
+ - path: ./empty.yml
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+agent_options:
+ path: ./agent-options.yml
+org_settings:
+ path: ./org-settings.yml
diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml
new file mode 100644
index 000000000..4c4ee3eb7
--- /dev/null
+++ b/pkg/spec/testdata/global_config_no_paths.yml
@@ -0,0 +1,181 @@
+# Test config
+controls: # Controls added to "No team"
+ macos_settings:
+ custom_settings:
+ - path: ./lib/macos-password.mobileconfig
+ windows_settings:
+ custom_settings:
+ - path: ./lib/windows-screenlock.xml
+ scripts:
+ - path: ./lib/collect-fleetd-logs.sh
+ enable_disk_encryption: true
+ 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_updates:
+ deadline_days: null
+ grace_period_days: null
+queries:
+ - name: Scheduled query stats
+ description: Collect osquery performance stats directly from osquery
+ query: SELECT *,
+ (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
+ FROM osquery_schedule;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: false
+ logging: snapshot
+ - name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - name: 😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: Passing policy
+ platform: linux,windows,darwin,chrome
+ description: This policy should always pass.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1;
+ - name: No root logins (macOS, Linux)
+ platform: linux,darwin
+ query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
+ WHERE username = "root"
+ AND time > (( SELECT unix_time FROM time ) - 3600 ))
+ critical: true
+ - name: 🔥 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+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:
+ debug_host_ids:
+ - 10728
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://fleet.example.com
+ org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: Fleet Device Management
+ smtp_settings:
+ authentication_method: authmethod_plain
+ authentication_type: authtype_username_password
+ configured: false
+ domain: ""
+ enable_smtp: false
+ enable_ssl_tls: true
+ enable_start_tls: true
+ password: ""
+ port: 587
+ sender_address: ""
+ server: ""
+ user_name: ""
+ verify_ssl_certs: true
+ sso_settings:
+ enable_jit_provisioning: false
+ enable_jit_role_sync: false
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: https://saml.example.com/entityid
+ idp_image_url: ""
+ idp_name: MockSAML
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: https://mocksaml.com/api/saml/metadata
+ integrations:
+ jira:
+ - api_token: JIRA_TOKEN
+ enable_failing_policies: true
+ enable_software_vulnerabilities: false
+ project_key: JIR
+ url: https://fleetdm.atlassian.net
+ username: reed@fleetdm.com
+ zendesk: []
+ mdm:
+ apple_bm_default_team: ""
+ end_user_authentication:
+ entity_id: ""
+ idp_name: ""
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: ""
+ webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://host.docker.internal:8080/bozo
+ enable_failing_policies_webhook: false
+ host_batch_size: 0
+ policy_ids: []
+ host_status_webhook:
+ days_count: 0
+ destination_url: ""
+ enable_host_status_webhook: false
+ host_percentage: 0
+ interval: 24h0m0s
+ vulnerabilities_webhook:
+ destination_url: ""
+ enable_vulnerabilities_webhook: false
+ host_batch_size: 0
+ fleet_desktop: # Applies to Fleet Premium only
+ transparency_url: https://fleetdm.com/transparency
+ host_expiry_settings: # Applies to all teams
+ host_expiry_enabled: false
+ features: # Features added to all teams
+ enable_host_users: true
+ enable_software_inventory: true
+ vulnerability_settings:
+ databases_path: ""
+ secrets: # These secrets are used to enroll hosts to the "All teams" team
+ - secret: SampleSecret123
+ - secret: ABC
diff --git a/pkg/spec/testdata/org-settings.yml b/pkg/spec/testdata/org-settings.yml
new file mode 100644
index 000000000..98038b207
--- /dev/null
+++ b/pkg/spec/testdata/org-settings.yml
@@ -0,0 +1,84 @@
+server_settings:
+ debug_host_ids:
+ - 10728
+ deferred_save_host: false
+ enable_analytics: true
+ live_query_disabled: false
+ query_reports_disabled: false
+ scripts_disabled: false
+ server_url: https://fleet.example.com
+org_info:
+ contact_url: https://fleetdm.com/company/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: Fleet Device Management
+smtp_settings:
+ authentication_method: authmethod_plain
+ authentication_type: authtype_username_password
+ configured: false
+ domain: ""
+ enable_smtp: false
+ enable_ssl_tls: true
+ enable_start_tls: true
+ password: ""
+ port: 587
+ sender_address: ""
+ server: ""
+ user_name: ""
+ verify_ssl_certs: true
+sso_settings:
+ enable_jit_provisioning: false
+ enable_jit_role_sync: false
+ enable_sso: true
+ enable_sso_idp_login: false
+ entity_id: https://saml.example.com/entityid
+ idp_image_url: ""
+ idp_name: MockSAML
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: https://mocksaml.com/api/saml/metadata
+integrations:
+ jira:
+ - api_token: JIRA_TOKEN
+ enable_failing_policies: true
+ enable_software_vulnerabilities: false
+ project_key: JIR
+ url: https://fleetdm.atlassian.net
+ username: reed@fleetdm.com
+ zendesk: []
+mdm:
+ apple_bm_default_team: ""
+ end_user_authentication:
+ entity_id: ""
+ idp_name: ""
+ issuer_uri: ""
+ metadata: ""
+ metadata_url: ""
+webhook_settings:
+ failing_policies_webhook:
+ destination_url: https://host.docker.internal:8080/bozo
+ enable_failing_policies_webhook: false
+ host_batch_size: 0
+ policy_ids: []
+ host_status_webhook:
+ days_count: 0
+ destination_url: ""
+ enable_host_status_webhook: false
+ host_percentage: 0
+ interval: 24h0m0s
+ vulnerabilities_webhook:
+ destination_url: ""
+ enable_vulnerabilities_webhook: false
+ host_batch_size: 0
+fleet_desktop: # Applies to Fleet Premium only
+ transparency_url: https://fleetdm.com/transparency
+host_expiry_settings: # Applies to all teams
+ host_expiry_enabled: false
+features: # Features added to all teams
+ enable_host_users: true
+ enable_software_inventory: true
+vulnerability_settings:
+ databases_path: ""
+secrets: # These secrets are used to enroll hosts to the "All teams" team
+ - secret: SampleSecret123
+ - secret: ABC
diff --git a/pkg/spec/testdata/team-settings.yml b/pkg/spec/testdata/team-settings.yml
new file mode 100644
index 000000000..620d16639
--- /dev/null
+++ b/pkg/spec/testdata/team-settings.yml
@@ -0,0 +1,14 @@
+secrets:
+ - secret: "SampleSecret123"
+ - secret: "ABC"
+webhook_settings:
+ failing_policies_webhook:
+ enable_failing_policies_webhook: true
+ destination_url: https://example.tines.com/webhook
+ policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
+features:
+ enable_host_users: true
+ enable_software_inventory: true
+host_expiry_settings:
+ host_expiry_enabled: true
+ host_expiry_window: 30
diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml
new file mode 100644
index 000000000..9dbf7f0de
--- /dev/null
+++ b/pkg/spec/testdata/team_config.yml
@@ -0,0 +1,27 @@
+name: Team1
+team_settings:
+ path: ./team-settings.yml
+agent_options:
+ path: ./agent-options.yml
+controls:
+ path: ./controls.yml
+queries:
+ - path: ./top.queries.yml
+ - path: ./empty.yml
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - path: ./top.policies.yml
+ - path: ./top.policies2.yml
+ - path: ./empty.yml
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml
new file mode 100644
index 000000000..207317e64
--- /dev/null
+++ b/pkg/spec/testdata/team_config_no_paths.yml
@@ -0,0 +1,111 @@
+name: Team1
+team_settings:
+ secrets:
+ - secret: "SampleSecret123"
+ - secret: "ABC"
+ webhook_settings:
+ failing_policies_webhook:
+ enable_failing_policies_webhook: true
+ destination_url: https://example.tines.com/webhook
+ policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
+ features:
+ enable_host_users: true
+ enable_software_inventory: true
+ host_expiry_settings:
+ host_expiry_enabled: true
+ host_expiry_window: 30
+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: /
+controls:
+ macos_settings:
+ custom_settings:
+ - path: ./lib/macos-password.mobileconfig
+ windows_settings:
+ custom_settings:
+ - path: ./lib/windows-screenlock.xml
+ scripts:
+ - path: ./lib/collect-fleetd-logs.sh
+ enable_disk_encryption: true
+ macos_setup:
+ bootstrap_package: null
+ enable_end_user_authentication: false
+ macos_setup_assistant: null
+ macos_updates:
+ deadline: null
+ minimum_version: null
+ windows_updates:
+ deadline_days: null
+ grace_period_days: null
+ macos_migration:
+ enable: false
+ mode: ""
+ webhook_url: ""
+ windows_enabled_and_configured: true
+queries:
+ - name: Scheduled query stats
+ description: Collect osquery performance stats directly from osquery
+ query: SELECT *,
+ (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
+ FROM osquery_schedule;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: false
+ logging: snapshot
+ - name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+ - name: osquery_info
+ query: SELECT * from osquery_info;
+ interval: 604800 # 1 week
+ platform: darwin,linux,windows,chrome
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
+policies:
+ - name: 😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: Passing policy
+ platform: linux,windows,darwin,chrome
+ description: This policy should always pass.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1;
+ - name: No root logins (macOS, Linux)
+ platform: linux,darwin
+ query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
+ WHERE username = "root"
+ AND time > (( SELECT unix_time FROM time ) - 3600 ))
+ critical: true
+ - name: 🔥 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+ - name: 😊😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
diff --git a/pkg/spec/testdata/top.policies.yml b/pkg/spec/testdata/top.policies.yml
new file mode 100644
index 000000000..46f6d5546
--- /dev/null
+++ b/pkg/spec/testdata/top.policies.yml
@@ -0,0 +1,16 @@
+- name: 😊 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
+- name: Passing policy
+ platform: linux,windows,darwin,chrome
+ description: This policy should always pass.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1;
+- name: No root logins (macOS, Linux)
+ platform: linux,darwin
+ query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
+ WHERE username = "root"
+ AND time > (( SELECT unix_time FROM time ) - 3600 ))
+ critical: true
diff --git a/pkg/spec/testdata/top.policies2.yml b/pkg/spec/testdata/top.policies2.yml
new file mode 100644
index 000000000..d4cf4d179
--- /dev/null
+++ b/pkg/spec/testdata/top.policies2.yml
@@ -0,0 +1,5 @@
+- name: 🔥 Failing policy
+ platform: linux
+ description: This policy should always fail.
+ resolution: There is no resolution for this policy.
+ query: SELECT 1 FROM osquery_info WHERE start_time < 0;
diff --git a/pkg/spec/testdata/top.queries.yml b/pkg/spec/testdata/top.queries.yml
new file mode 100644
index 000000000..988f52158
--- /dev/null
+++ b/pkg/spec/testdata/top.queries.yml
@@ -0,0 +1,19 @@
+- name: Scheduled query stats
+ description: Collect osquery performance stats directly from osquery
+ query: SELECT *,
+ (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
+ FROM osquery_schedule;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: false
+ logging: snapshot
+- name: orbit_info
+ query: SELECT * from orbit_info;
+ interval: 0
+ platform: darwin,linux,windows
+ min_osquery_version: all
+ observer_can_run: false
+ automations_enabled: true
+ logging: snapshot
diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go
index b642478e1..63466da2e 100644
--- a/server/datastore/mysql/policies.go
+++ b/server/datastore/mysql/policies.go
@@ -599,7 +599,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
checksum
) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s)
ON DUPLICATE KEY UPDATE
- name = VALUES(name),
query = VALUES(query),
description = VALUES(description),
author_id = VALUES(author_id),
@@ -610,12 +609,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
)
for _, spec := range specs {
- // Validate that the team is not being changed
- err := validatePolicyTeamChange(ctx, ds, spec)
- if err != nil {
- return err
- }
-
res, err := tx.ExecContext(ctx,
query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
)
@@ -637,44 +630,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
})
}
-// Changing the team of a policy is not allowed, so check if the policy exists
-// and return an error if the team is changing
-func validatePolicyTeamChange(ctx context.Context, ds *Datastore, spec *fleet.PolicySpec) error {
- policy, err := ds.PolicyByName(ctx, spec.Name)
- if err != nil {
- if !fleet.IsNotFound(err) {
- return ctxerr.Wrap(ctx, err, "Error fetching policy by name")
- }
- // If no rows found there is no policy to validate against
- return nil
- }
-
- // If the policy exists
- if policy != nil {
- // Check if the policy is global
- if policy.TeamID == nil {
- if spec.Team != "" {
- return ctxerr.Wrap(ctx, &fleet.BadRequestError{
- Message: fmt.Sprintf("cannot change the team of an existing global policy"),
- })
- }
- } else {
- // If it's not global, fetch the team name and compare
- team, err := ds.Team(ctx, *policy.TeamID)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "Error fetching team by ID")
- }
-
- if spec.Team != team.Name {
- return ctxerr.Wrap(ctx, &fleet.BadRequestError{
- Message: fmt.Sprintf("cannot change the team of an existing policy"),
- })
- }
- }
- }
- return nil
-}
-
func amountPoliciesDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
var amount int
err := sqlx.GetContext(ctx, db, &amount, `SELECT count(*) FROM policies`)
diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go
index 5e2bcbcdc..d6b55aa22 100644
--- a/server/datastore/mysql/policies_test.go
+++ b/server/datastore/mysql/policies_test.go
@@ -1369,17 +1369,19 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
assert.Equal(t, "some other resolution updated", *teamPolicies[0].Resolution)
assert.Equal(t, "windows", teamPolicies[0].Platform)
- // Test error when modifying team on existing policy
- require.Error(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
- {
- Name: "query1",
- Query: "select 1 from updated again;",
- Description: "query1 desc updated again",
- Resolution: "some resolution updated again",
- Team: "team1", // Modifying teams on existing policies is not allowed
- Platform: "",
- },
- }))
+ // Creating the same policy for a different team is allowed.
+ require.NoError(
+ t, ds.ApplyPolicySpecs(
+ ctx, user1.ID, []*fleet.PolicySpec{
+ {
+ Name: "query1",
+ Query: "select 1 from updated again;",
+ Description: "query1 desc updated again",
+ Resolution: "some resolution updated again",
+ Team: "team1",
+ Platform: "",
+ },
+ }))
}
func testPoliciesSave(t *testing.T, ds *Datastore) {
diff --git a/server/fleet/policies.go b/server/fleet/policies.go
index 092117d9b..78d57f86c 100644
--- a/server/fleet/policies.go
+++ b/server/fleet/policies.go
@@ -228,14 +228,18 @@ func (p PolicySpec) Verify() error {
return nil
}
-// return first duplicate name of policies or empty string if no duplicates found
+// FirstDuplicatePolicySpecName returns first duplicate name of policies (in a team) or empty string if no duplicates found
func FirstDuplicatePolicySpecName(specs []*PolicySpec) string {
- names := make(map[string]struct{})
+ teams := make(map[string]map[string]struct{})
for _, spec := range specs {
- if _, ok := names[spec.Name]; ok {
- return spec.Name
+ if team, ok := teams[spec.Team]; ok {
+ if _, ok = team[spec.Name]; ok {
+ return spec.Name
+ }
+ team[spec.Name] = struct{}{}
+ } else {
+ teams[spec.Team] = map[string]struct{}{spec.Name: {}}
}
- names[spec.Name] = struct{}{}
}
return ""
}
diff --git a/server/fleet/service.go b/server/fleet/service.go
index fdfaaa946..6753907b3 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -881,7 +881,10 @@ type Service interface {
// BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified
// team or for hosts with no team.
- BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool) error
+ BatchSetMDMProfiles(
+ ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool,
+ assumeEnabled bool,
+ ) error
///////////////////////////////////////////////////////////////////////////////
// Common MDM
diff --git a/server/service/client.go b/server/service/client.go
index e03af0a54..cd991f912 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -322,7 +322,7 @@ func (c *Client) ApplyGroup(
baseDir string,
logf func(format string, args ...interface{}),
opts fleet.ApplySpecOptions,
-) error {
+) (map[string]uint, error) {
logfn := func(format string, args ...interface{}) {
if logf != nil {
logf(format, args...)
@@ -335,7 +335,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyQueries(specs.Queries); err != nil {
- return fmt.Errorf("applying queries: %w", err)
+ return nil, fmt.Errorf("applying queries: %w", err)
}
logfn("[+] applied %d queries\n", len(specs.Queries))
}
@@ -346,7 +346,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyLabels(specs.Labels); err != nil {
- return fmt.Errorf("applying labels: %w", err)
+ return nil, fmt.Errorf("applying labels: %w", err)
}
logfn("[+] applied %d labels\n", len(specs.Labels))
}
@@ -358,7 +358,9 @@ func (c *Client) ApplyGroup(
} else {
// Policy names must be unique, return error if duplicate policy names are found
if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" {
- return fmt.Errorf("applying policies: policy names must be globally unique. Please correct policy %q and try again.", policyName)
+ return nil, fmt.Errorf(
+ "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName,
+ )
}
// If set, override the team in all the policies.
@@ -368,7 +370,7 @@ func (c *Client) ApplyGroup(
}
}
if err := c.ApplyPolicies(specs.Policies); err != nil {
- return fmt.Errorf("applying policies: %w", err)
+ return nil, fmt.Errorf("applying policies: %w", err)
}
logfn("[+] applied %d policies\n", len(specs.Policies))
}
@@ -379,7 +381,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyPacks(specs.Packs); err != nil {
- return fmt.Errorf("applying packs: %w", err)
+ return nil, fmt.Errorf("applying packs: %w", err)
}
logfn("[+] applied %d packs\n", len(specs.Packs))
}
@@ -400,33 +402,44 @@ func (c *Client) ApplyGroup(
if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(allCustomSettings) > 0 {
fileContents, err := getProfilesContents(baseDir, allCustomSettings)
if err != nil {
- return err
+ return nil, err
}
- if err := c.ApplyNoTeamProfiles(fileContents, opts); err != nil {
- return fmt.Errorf("applying custom settings: %w", err)
+ // Figure out if MDM should be enabled.
+ assumeEnabled := false
+ // This cast is safe because we've already checked AppConfig when extracting custom settings
+ mdmConfigMap, ok := specs.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
+ if ok {
+ mdmEnabled, ok := mdmConfigMap["windows_enabled_and_configured"]
+ if ok {
+ assumeEnabled, ok = mdmEnabled.(bool)
+ assumeEnabled = ok && assumeEnabled
+ }
+ }
+ if err := c.ApplyNoTeamProfiles(fileContents, opts, assumeEnabled); err != nil {
+ return nil, fmt.Errorf("applying custom settings: %w", err)
}
}
if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil {
if macosSetup.BootstrapPackage.Value != "" {
pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value)
if err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
if !opts.DryRun {
if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
}
}
if macosSetup.MacOSSetupAssistant.Value != "" {
content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value))
if err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
if !opts.DryRun {
if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
}
}
@@ -437,7 +450,7 @@ func (c *Client) ApplyGroup(
for i, f := range files {
b, err := os.ReadFile(f)
if err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
@@ -445,11 +458,11 @@ func (c *Client) ApplyGroup(
}
}
if err := c.ApplyNoTeamScripts(scriptPayloads, opts); err != nil {
- return fmt.Errorf("applying custom settings: %w", err)
+ return nil, fmt.Errorf("applying custom settings: %w", err)
}
}
if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied fleet config\n")
@@ -460,15 +473,16 @@ func (c *Client) ApplyGroup(
if specs.EnrollSecret != nil {
if opts.DryRun {
- logfn("[!] ignoring enroll secrets, dry run mode only supported for 'config' and 'team' specs\n")
+ logfn("[+] would've applied enroll secrets\n")
} else {
if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret); err != nil {
- return fmt.Errorf("applying enroll secrets: %w", err)
+ return nil, fmt.Errorf("applying enroll secrets: %w", err)
}
logfn("[+] applied enroll secrets\n")
}
}
+ var teamIDsByName map[string]uint
if len(specs.Teams) > 0 {
// extract the teams' custom settings and resolve the files immediately, so
// that any non-existing file error is found before applying the specs.
@@ -478,7 +492,7 @@ func (c *Client) ApplyGroup(
for k, paths := range tmMDMSettings {
fileContents, err := getProfilesContents(baseDir, paths)
if err != nil {
- return err
+ return nil, err
}
tmFileContents[k] = fileContents
}
@@ -490,14 +504,14 @@ func (c *Client) ApplyGroup(
if setup.BootstrapPackage.Value != "" {
bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value)
if err != nil {
- return fmt.Errorf("applying teams: %w", err)
+ return nil, fmt.Errorf("applying teams: %w", err)
}
tmBootstrapPackages[k] = bp
}
if setup.MacOSSetupAssistant.Value != "" {
b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value))
if err != nil {
- return fmt.Errorf("applying teams: %w", err)
+ return nil, fmt.Errorf("applying teams: %w", err)
}
tmMacSetupAssistants[k] = b
}
@@ -511,7 +525,7 @@ func (c *Client) ApplyGroup(
for i, f := range files {
b, err := os.ReadFile(f)
if err != nil {
- return fmt.Errorf("applying fleet config: %w", err)
+ return nil, fmt.Errorf("applying fleet config: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
@@ -523,15 +537,22 @@ func (c *Client) ApplyGroup(
// Next, apply the teams specs before saving the profiles, so that any
// non-existing team gets created.
- teamIDsByName, err := c.ApplyTeams(specs.Teams, opts)
+ var err error
+ teamIDsByName, err = c.ApplyTeams(specs.Teams, opts)
if err != nil {
- return fmt.Errorf("applying teams: %w", err)
+ return nil, fmt.Errorf("applying teams: %w", err)
}
if len(tmFileContents) > 0 {
for tmName, profs := range tmFileContents {
- if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil {
- return fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
+ teamID, ok := teamIDsByName[tmName]
+ if opts.DryRun && (teamID == 0 || !ok) {
+ logfn("[+] would've applied MDM profiles for new team %s\n", tmName)
+ } else {
+ logfn("[+] applying MDM profiles for team %s\n", tmName)
+ if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil {
+ return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
+ }
}
}
}
@@ -539,12 +560,12 @@ func (c *Client) ApplyGroup(
for tmName, tmID := range teamIDsByName {
if bp, ok := tmBootstrapPackages[tmName]; ok {
if err := c.EnsureBootstrapPackage(bp, tmID); err != nil {
- return fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
+ return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
}
}
if b, ok := tmMacSetupAssistants[tmName]; ok {
if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil {
- return fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
+ return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
}
}
}
@@ -552,7 +573,7 @@ func (c *Client) ApplyGroup(
if len(tmScriptsPayloads) > 0 {
for tmName, scripts := range tmScriptsPayloads {
if err := c.ApplyTeamScripts(tmName, scripts, opts); err != nil {
- return fmt.Errorf("applying scripts for team %q: %w", tmName, err)
+ return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err)
}
}
}
@@ -568,12 +589,12 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil {
- return fmt.Errorf("applying user roles: %w", err)
+ return nil, fmt.Errorf("applying user roles: %w", err)
}
logfn("[+] applied user roles\n")
}
}
- return nil
+ return teamIDsByName, nil
}
func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup {
@@ -832,3 +853,179 @@ func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOS
}
return m
}
+
+// DoGitOps applies the GitOps config to Fleet.
+func (c *Client) DoGitOps(
+ ctx context.Context,
+ config *spec.GitOps,
+ baseDir string,
+ logf func(format string, args ...interface{}),
+ dryRun bool,
+) error {
+ var err error
+ logFn := func(format string, args ...interface{}) {
+ if logf != nil {
+ logf(format, args...)
+ }
+ }
+ group := spec.Group{}
+ scripts := make([]interface{}, len(config.Controls.Scripts))
+ for i, script := range config.Controls.Scripts {
+ scripts[i] = *script.Path
+ }
+ var mdmAppConfig map[string]interface{}
+ var team map[string]interface{}
+ if config.TeamName == nil {
+ group.AppConfig = config.OrgSettings
+ group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)}
+ group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions
+ delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup
+ if _, ok := group.AppConfig.(map[string]interface{})["mdm"]; !ok {
+ group.AppConfig.(map[string]interface{})["mdm"] = map[string]interface{}{}
+ }
+ mdmAppConfig = group.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
+ mdmAppConfig["macos_migration"] = config.Controls.MacOSMigration
+ mdmAppConfig["windows_enabled_and_configured"] = config.Controls.WindowsEnabledAndConfigured
+ group.AppConfig.(map[string]interface{})["scripts"] = scripts
+ } else {
+ team = make(map[string]interface{})
+ team["name"] = *config.TeamName
+ team["agent_options"] = config.AgentOptions
+ if hostExpirySettings, ok := config.TeamSettings["host_expiry_settings"]; ok {
+ team["host_expiry_settings"] = hostExpirySettings
+ }
+ if features, ok := config.TeamSettings["features"]; ok {
+ team["features"] = features
+ }
+ team["scripts"] = scripts
+ team["secrets"] = config.TeamSettings["secrets"]
+ team["mdm"] = map[string]interface{}{}
+ mdmAppConfig = team["mdm"].(map[string]interface{})
+ }
+ // Common controls settings between org and team settings
+ mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings
+ mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates
+ mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup
+ mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates
+ mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings
+ mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption
+ if config.TeamName != nil {
+ rawTeam, err := json.Marshal(team)
+ if err != nil {
+ return fmt.Errorf("error marshalling team spec: %w", err)
+ }
+ group.Teams = []json.RawMessage{rawTeam}
+ }
+
+ // Apply org settings, scripts, enroll secrets, and controls
+ teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplySpecOptions{DryRun: dryRun})
+ if err != nil {
+ return err
+ }
+ if config.TeamName != nil {
+ teamID, ok := teamIDsByName[*config.TeamName]
+ if !ok || teamID == 0 {
+ if dryRun {
+ logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName)
+ return nil
+ }
+ return fmt.Errorf("team %s not created", *config.TeamName)
+ }
+ config.TeamID = &teamID
+ }
+
+ err = c.doGitOpsPolicies(config, logFn, dryRun)
+ if err != nil {
+ return err
+ }
+ err = c.doGitOpsQueries(config, logFn, dryRun)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
+ // Get the ids and names of current policies to figure out which ones to delete
+ policies, err := c.GetPolicies(config.TeamID)
+ if err != nil {
+ return fmt.Errorf("error getting current policies: %w", err)
+ }
+ if len(config.Policies) > 0 {
+ numPolicies := len(config.Policies)
+ logFn("[+] syncing %d policies\n", numPolicies)
+ if !dryRun {
+ // Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps.
+ if err := c.ApplyPolicies(config.Policies); err != nil {
+ return fmt.Errorf("error applying policies: %w", err)
+ }
+ logFn("[+] synced %d policies\n", numPolicies)
+ }
+ }
+ var policiesToDelete []uint
+ for _, oldItem := range policies {
+ found := false
+ for _, newItem := range config.Policies {
+ if oldItem.Name == newItem.Name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ policiesToDelete = append(policiesToDelete, oldItem.ID)
+ fmt.Printf("[-] deleting policy %s\n", oldItem.Name)
+ }
+ }
+ if len(policiesToDelete) > 0 {
+ logFn("[-] deleting %d policies\n", len(policiesToDelete))
+ if !dryRun {
+ if err := c.DeletePolicies(config.TeamID, policiesToDelete); err != nil {
+ return fmt.Errorf("error deleting policies: %w", err)
+ }
+ }
+ }
+ return nil
+}
+
+func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
+ // Get the ids and names of current queries to figure out which ones to delete
+ queries, err := c.GetQueries(config.TeamID, nil)
+ if err != nil {
+ return fmt.Errorf("error getting current queries: %w", err)
+ }
+ if len(config.Queries) > 0 {
+ numQueries := len(config.Queries)
+ logFn("[+] syncing %d queries\n", numQueries)
+ if !dryRun {
+ // Note: We are reusing the spec flow here for adding/updating queries, instead of creating a new flow for GitOps.
+ if err := c.ApplyQueries(config.Queries); err != nil {
+ return fmt.Errorf("error applying queries: %w", err)
+ }
+ logFn("[+] synced %d queries\n", numQueries)
+ }
+ }
+ var queriesToDelete []uint
+ for _, oldQuery := range queries {
+ found := false
+ for _, newQuery := range config.Queries {
+ if oldQuery.Name == newQuery.Name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ queriesToDelete = append(queriesToDelete, oldQuery.ID)
+ fmt.Printf("[-] deleting query %s\n", oldQuery.Name)
+ }
+ }
+ if len(queriesToDelete) > 0 {
+ logFn("[-] deleting %d queries\n", len(queriesToDelete))
+ if !dryRun {
+ if err := c.DeleteQueries(queriesToDelete); err != nil {
+ return fmt.Errorf("error deleting queries: %w", err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/server/service/client_appconfig.go b/server/service/client_appconfig.go
index 75e6e91c6..52894caa0 100644
--- a/server/service/client_appconfig.go
+++ b/server/service/client_appconfig.go
@@ -14,9 +14,16 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
// in no team.
-func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error {
+func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error {
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
- return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery())
+ query := opts.RawQuery()
+ if assumeEnabled {
+ if query != "" {
+ query += "&"
+ }
+ query += "assume_enabled=true"
+ }
+ return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query)
}
// GetAppConfig fetches the application config from the server API
diff --git a/server/service/client_policies.go b/server/service/client_policies.go
index 78efe8b77..a8425bebf 100644
--- a/server/service/client_policies.go
+++ b/server/service/client_policies.go
@@ -1,5 +1,10 @@
package service
+import (
+ "fmt"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+)
+
func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platform string) error {
req := globalPolicyRequest{
Name: name,
@@ -12,3 +17,44 @@ func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platfo
var responseBody globalPolicyResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}
+
+// ApplyPolicies sends the list of Policies to be applied to the
+// Fleet instance.
+func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
+ req := applyPolicySpecsRequest{Specs: specs}
+ verb, path := "POST", "/api/latest/fleet/spec/policies"
+ var responseBody applyPolicySpecsResponse
+ return c.authenticatedRequest(req, verb, path, &responseBody)
+}
+
+// GetPolicies retrieves the list of Policies. Inherited policies are excluded.
+func (c *Client) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
+ verb, path := "GET", ""
+ if teamID != nil {
+ path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies", *teamID)
+ } else {
+ path = "/api/latest/fleet/policies"
+ }
+ // The response body also works for listTeamPoliciesResponse because they contain some of the same members.
+ var responseBody listGlobalPoliciesResponse
+ err := c.authenticatedRequest(nil, verb, path, &responseBody)
+ if err != nil {
+ return nil, err
+ }
+ return responseBody.Policies, nil
+}
+
+// DeletePolicies deletes several policies.
+func (c *Client) DeletePolicies(teamID *uint, IDs []uint) error {
+ verb, path := "POST", ""
+ req := deleteTeamPoliciesRequest{IDs: IDs}
+ if teamID != nil {
+ path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", *teamID)
+ req.TeamID = *teamID
+ } else {
+ path = "/api/latest/fleet/policies/delete"
+ }
+ // The response body also works for deleteTeamPoliciesResponse because they contain some of the same members.
+ var responseBody deleteGlobalPoliciesResponse
+ return c.authenticatedRequest(req, verb, path, &responseBody)
+}
diff --git a/server/service/client_queries.go b/server/service/client_queries.go
index 0d0f8ce2f..146cac01a 100644
--- a/server/service/client_queries.go
+++ b/server/service/client_queries.go
@@ -52,3 +52,11 @@ func (c *Client) DeleteQuery(name string) error {
var responseBody deleteQueryResponse
return c.authenticatedRequest(nil, verb, path, &responseBody)
}
+
+// DeleteQueries deletes several queries.
+func (c *Client) DeleteQueries(IDs []uint) error {
+ req := deleteQueriesRequest{IDs: IDs}
+ verb, path := "POST", "/api/latest/fleet/queries/delete"
+ var responseBody deleteQueriesResponse
+ return c.authenticatedRequest(req, verb, path, &responseBody)
+}
diff --git a/server/service/client_teams.go b/server/service/client_teams.go
index d11e972ca..fa59b4178 100644
--- a/server/service/client_teams.go
+++ b/server/service/client_teams.go
@@ -74,15 +74,6 @@ func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBat
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query.Encode())
}
-// ApplyPolicies sends the list of Policies to be applied to the
-// Fleet instance.
-func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
- req := applyPolicySpecsRequest{Specs: specs}
- verb, path := "POST", "/api/latest/fleet/spec/policies"
- var responseBody applyPolicySpecsResponse
- return c.authenticatedRequest(req, verb, path, &responseBody)
-}
-
// ApplyTeamScripts sends the list of scripts to be applied for the specified
// team.
func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error {
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 6428a26dd..5dc6a2295 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -317,8 +317,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
- // dry-run never returns id to name mappings as it may not have them
- require.Empty(t, applyResp.TeamIDsByName)
+ assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
// dry-run with macos disk encryption set to true
teamSpecs = map[string]any{
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 947c65160..01c87bfd7 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -10647,7 +10647,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp, "dry_run", "true")
- require.Len(t, applyResp.TeamIDsByName, 0)
+ assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
diff --git a/server/service/mdm.go b/server/service/mdm.go
index aa2bccef2..3f1bf6aab 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -1393,10 +1393,11 @@ func (svc *Service) validateProfileLabels(ctx context.Context, labelNames []stri
////////////////////////////////////////////////////////////////////////////////
type batchSetMDMProfilesRequest struct {
- TeamID *uint `json:"-" query:"team_id,optional"`
- TeamName *string `json:"-" query:"team_name,optional"`
- DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
- Profiles backwardsCompatProfilesParam `json:"profiles"`
+ TeamID *uint `json:"-" query:"team_id,optional"`
+ TeamName *string `json:"-" query:"team_name,optional"`
+ DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
+ AssumeEnabled bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled
+ Profiles backwardsCompatProfilesParam `json:"profiles"`
}
type backwardsCompatProfilesParam []fleet.MDMProfileBatchPayload
@@ -1439,13 +1440,16 @@ func (r batchSetMDMProfilesResponse) Status() int { return http.StatusNoContent
func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*batchSetMDMProfilesRequest)
- if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil {
+ if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false, req.AssumeEnabled); err != nil {
return batchSetMDMProfilesResponse{Err: err}, nil
}
return batchSetMDMProfilesResponse{}, nil
}
-func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool) error {
+func (svc *Service) BatchSetMDMProfiles(
+ ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool,
+ assumeEnabled bool,
+) error {
var err error
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
return err
@@ -1455,6 +1459,9 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
+ if assumeEnabled {
+ appCfg.MDM.WindowsEnabledAndConfigured = true
+ }
if err := validateProfiles(profiles); err != nil {
return ctxerr.Wrap(ctx, err, "validating profiles")
diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go
index 707fa83fb..06b8e1385 100644
--- a/server/service/mdm_test.go
+++ b/server/service/mdm_test.go
@@ -1389,7 +1389,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
}
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier})
- err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false)
+ err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false, false)
if tt.wantErr == "" {
require.NoError(t, err)
require.True(t, ds.BatchSetMDMProfilesFuncInvoked)
diff --git a/server/service/scripts.go b/server/service/scripts.go
index 8ada62250..7924140f0 100644
--- a/server/service/scripts.go
+++ b/server/service/scripts.go
@@ -759,6 +759,10 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT
if maybeTmID != nil || maybeTmName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName)
if err != nil {
+ // If this is a dry run, the team may not have been created yet
+ if dryRun && fleet.IsNotFound(err) {
+ return nil
+ }
return err
}
teamID = &team.ID