Skip to content

Commit

Permalink
Backend build for script automation (#22472)
Browse files Browse the repository at this point in the history
#22115, #22116

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

No changes file, as FE changes file covers the entire feature

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
Co-authored-by: Tim Lee <timlee@fleetdm.com>
  • Loading branch information
3 people authored Oct 4, 2024
1 parent 119ca54 commit e4df7ab
Show file tree
Hide file tree
Showing 38 changed files with 1,471 additions and 136 deletions.
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ define HELP_TEXT
make generate-dev - Generate and bundle required code in a watch loop
make generate-doc - Generate updated API documentation for activities, osquery flags

make clean - Clean all build artifacts
make dump-test-schema - update schema.sql from current migrations
make generate-mock - update mock data store

make clean - Clean all build artifacts
make clean-assets - Clean assets only

make build - Build the code
Expand Down
2 changes: 1 addition & 1 deletion cmd/fleetctl/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func applyCommand() *cli.Command {
opts.TeamForPolicies = policiesTeamName
}
baseDir := filepath.Dir(flFilename)
_, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts)
_, _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/fleetctl/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2306,8 +2306,8 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
return nil
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
Expand Down
50 changes: 38 additions & 12 deletions cmd/fleetctl/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ func TestGitOpsBasicGlobalFree(t *testing.T) {
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
Expand Down Expand Up @@ -211,7 +213,9 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) {
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
Expand Down Expand Up @@ -327,7 +331,9 @@ func TestGitOpsBasicTeam(t *testing.T) {
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
) (updates fleet.MDMProfilesUpdates, err error) {
Expand Down Expand Up @@ -516,9 +522,18 @@ func TestGitOpsFullGlobal(t *testing.T) {
)

var appliedScripts []*fleet.Script
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
appliedScripts = scripts
return nil
var scriptResponses []fleet.ScriptResponse
for _, script := range scripts {
scriptResponses = append(scriptResponses, fleet.ScriptResponse{
ID: script.ID,
Name: script.Name,
TeamID: script.TeamID,
})
}

return scriptResponses, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
Expand Down Expand Up @@ -704,9 +719,18 @@ func TestGitOpsFullTeam(t *testing.T) {
}

var appliedScripts []*fleet.Script
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
appliedScripts = scripts
return nil
var scriptResponses []fleet.ScriptResponse
for _, script := range scripts {
scriptResponses = append(scriptResponses, fleet.ScriptResponse{
ID: script.ID,
Name: script.Name,
TeamID: script.TeamID,
})
}

return scriptResponses, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
Expand Down Expand Up @@ -1040,9 +1064,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) {
assert.Empty(t, winProfiles)
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
assert.Empty(t, scripts)
return nil
return []fleet.ScriptResponse{}, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(
ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string,
Expand Down Expand Up @@ -1318,9 +1342,9 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) {
assert.Empty(t, winProfiles)
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
assert.Empty(t, scripts)
return nil
return []fleet.ScriptResponse{}, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(
ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string,
Expand Down Expand Up @@ -2190,7 +2214,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(
ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string,
) (updates fleet.MDMProfilesUpdates, err error) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/fleetctl/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,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, nil, fleet.ApplyClientSpecOptions{})
_, _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{})
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/fleetctl/scripts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func TestRunScriptCommand(t *testing.T) {
ds.GetScriptIDByNameFunc = func(ctx context.Context, name string, teamID *uint) (uint, error) {
return 1, nil
}
ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hid uint, scriptID uint) ([]*uint, error) {
return []*uint{}, nil
ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hid uint, scriptID uint) (bool, error) {
return false, nil
}

generateValidPath := func() string {
Expand Down
53 changes: 52 additions & 1 deletion pkg/spec/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,18 @@ type Policy struct {

type GitOpsPolicySpec struct {
fleet.PolicySpec
RunScript *PolicyRunScript `json:"run_script"`
InstallSoftware *PolicyInstallSoftware `json:"install_software"`
// InstallSoftwareURL is populated after parsing the software installer yaml
// referenced by InstallSoftware.PackagePath.
InstallSoftwareURL string `json:"-"`
// RunScriptName is populated after confirming the script exists on both the file system
// and in the controls scripts list for the same team
RunScriptName *string `json:"-"`
}

type PolicyRunScript struct {
Path string `json:"path"`
}

type PolicyInstallSoftware struct {
Expand Down Expand Up @@ -168,7 +176,7 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
multiError = parseSoftware(top, result, baseDir, multiError)
}

// Policies can reference software installers, thus we parse them after parseSoftware.
// Policies can reference software installers and scripts, thus we parse them after parseSoftware and parseControls.
multiError = parsePolicies(top, result, baseDir, multiError)

return result, multiError.ErrorOrNil()
Expand Down Expand Up @@ -465,6 +473,10 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", item.Name, err))
continue
}
if err := parsePolicyRunScript(baseDir, result.TeamName, &item, result.Controls.Scripts); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", item.Name, err))
continue
}
result.Policies = append(result.Policies, &item.GitOpsPolicySpec)
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
Expand Down Expand Up @@ -496,6 +508,10 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", pp.Name, err))
continue
}
if err := parsePolicyRunScript(baseDir, result.TeamName, pp, result.Controls.Scripts); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", pp.Name, err))
continue
}
result.Policies = append(result.Policies, &pp.GitOpsPolicySpec)
}
}
Expand Down Expand Up @@ -533,6 +549,41 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
return multiError
}

func parsePolicyRunScript(baseDir string, teamName *string, policy *Policy, scripts []BaseItem) error {
if policy.RunScript == nil {
policy.ScriptID = ptr.Uint(0) // unset the script
return nil
}
if policy.RunScript != nil && policy.RunScript.Path != "" && teamName == nil {
return errors.New("run_script can only be set on team policies")
}

if policy.RunScript.Path == "" {
return errors.New("empty run_script path")
}

_, err := os.Stat(resolveApplyRelativePath(baseDir, policy.RunScript.Path))
if err != nil {
return fmt.Errorf("script file does not exist %q: %v", policy.RunScript.Path, err)
}

scriptOnTeamFound := false
for _, script := range scripts {
if policy.RunScript.Path == *script.Path {
scriptOnTeamFound = true
break
}
}
if !scriptOnTeamFound {
return fmt.Errorf("policy script not found on team: %s", policy.RunScript.Path)
}

scriptName := filepath.Base(policy.RunScript.Path)
policy.RunScriptName = &scriptName

return nil
}

func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec) error {
if policy.InstallSoftware == nil {
policy.SoftwareTitleID = ptr.Uint(0) // unset the installer
Expand Down
82 changes: 81 additions & 1 deletion pkg/spec/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func TestValidGitOpsYaml(t *testing.T) {
// Check policies
expectedPoliciesCount := 5
if test.isTeam {
expectedPoliciesCount = 6
expectedPoliciesCount = 8
}
require.Len(t, gitops.Policies, expectedPoliciesCount)
assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name)
Expand All @@ -255,6 +255,14 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.Equal(t, "Microsoft Teams on macOS installed and up to date", gitops.Policies[5].Name)
assert.NotNil(t, gitops.Policies[5].InstallSoftware)
assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath)

assert.Equal(t, "Script run policy", gitops.Policies[6].Name)
assert.NotNil(t, gitops.Policies[6].RunScript)
assert.Equal(t, "./lib/collect-fleetd-logs.sh", gitops.Policies[6].RunScript.Path)

assert.Equal(t, "🔥 Failing policy with script", gitops.Policies[7].Name)
assert.NotNil(t, gitops.Policies[7].RunScript)
assert.Equal(t, "./lib/collect-fleetd-logs.sh", gitops.Policies[7].RunScript.Path)
}
},
)
Expand Down Expand Up @@ -839,6 +847,20 @@ policies:
assert.ErrorContains(t, err, "install_software can only be set on team policies")
}

func TestGitOpsGlobalPolicyWithRunScript(t *testing.T) {
t.Parallel()
config := getGlobalConfig([]string{"policies"})
config += `
policies:
- name: Some policy
query: SELECT 1;
run_script:
path: ./some_path.sh
`
_, err := gitOpsFromString(t, config)
assert.ErrorContains(t, err, "run_script can only be set on team policies")
}

func TestGitOpsTeamPolicyWithInvalidInstallSoftware(t *testing.T) {
t.Parallel()
config := getTeamConfig([]string{"policies"})
Expand Down Expand Up @@ -939,6 +961,64 @@ software:
assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file")
}

func TestGitOpsTeamPolicyWithInvalidRunScript(t *testing.T) {
t.Parallel()
config := getTeamConfig([]string{"policies"})
config += `
policies:
- name: Some policy
query: SELECT 1;
run_script:
path: ./some_path.sh
`
_, err := gitOpsFromString(t, config)
assert.ErrorContains(t, err, "script file does not exist")

config = getTeamConfig([]string{"policies"})
config += `
policies:
- name: Some policy
query: SELECT 1;
run_script:
path:
`
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "empty run_script path")

// Policy references a script not present in the team.
config = getTeamConfig([]string{"policies"})
config += `
policies:
- path: ./script-policy.yml
software:
controls:
scripts:
- path: ./top.policies2.yml
`
path, basePath := createTempFile(t, "", config)
err = file.Copy(
filepath.Join("testdata", "script-policy.yml"),
filepath.Join(basePath, "script-policy.yml"),
0o755,
)
require.NoError(t, err)
err = file.Copy(
filepath.Join("testdata", "lib", "collect-fleetd-logs.sh"),
filepath.Join(basePath, "lib", "collect-fleetd-logs.sh"),
0o755,
)
require.NoError(t, err)
appConfig := fleet.EnrichedAppConfig{}
appConfig.License = &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
_, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf)
assert.ErrorContains(t, err,
"policy script not found on team",
)
}

func getGlobalConfig(optsToExclude []string) string {
return getBaseConfig(topLevelOptions, optsToExclude)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/spec/testdata/lib/collect-fleetd-logs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# collect fleetd logs
7 changes: 7 additions & 0 deletions pkg/spec/testdata/script-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- name: 🔥 Failing policy with script
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;
run_script:
path: ./lib/collect-fleetd-logs.sh
7 changes: 7 additions & 0 deletions pkg/spec/testdata/team_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ policies:
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- path: ./team_install_software.policies.yml
- name: Script run policy
platform: linux
description: This should run a script on failure
query: SELECT * from osquery_info;
run_script:
path: ./lib/collect-fleetd-logs.sh
- path: ./script-policy.yml
software:
packages:
- path: ./microsoft-teams.pkg.software.yml
Expand Down
Loading

0 comments on commit e4df7ab

Please sign in to comment.