no team software gitops (#20847)

#20464 

Adding gitops support for a top level `software` key to be used to
manage installable software into "no team".

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [X] Added/updated tests
- [X] Manual QA for all new/changed functionality

---------

Co-authored-by: Victor Lyuboslavsky <victor.lyuboslavsky@gmail.com>
This commit is contained in:
Tim Lee
2024-08-05 14:39:10 -03:00
committed by GitHub
co-authored by Victor Lyuboslavsky
parent 7f95ae46a3
commit a6a9a2e1c2
32 changed files with 492 additions and 151 deletions
+1 -1
View File
@@ -90,7 +90,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, nil, opts)
if err != nil {
return err
}
+1 -1
View File
@@ -90,7 +90,7 @@ func gitopsCommand() *cli.Command {
secrets := make(map[string]struct{})
for _, flFilename := range flFilenames.Value() {
baseDir := filepath.Dir(flFilename)
config, err := spec.GitOpsFromFile(flFilename, baseDir)
config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig)
if err != nil {
return err
}
@@ -176,6 +176,7 @@ contexts:
fmt.Sprintf(
`
controls:
software:
queries:
policies:
agent_options:
@@ -230,5 +231,4 @@ team_settings:
for _, fileName := range teamFileNames {
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName})
}
}
+45 -2
View File
@@ -36,7 +36,7 @@ const (
orgName = "GitOps Test"
)
func TestFilenameValidation(t *testing.T) {
func TestFilenameGitOpsValidation(t *testing.T) {
filename := strings.Repeat("a", filenameMaxLength+1)
_, err := runAppNoChecks([]string{"gitops", "-f", filename})
assert.ErrorContains(t, err, "file name must be less than")
@@ -207,6 +207,9 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) {
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return &fleet.Job{}, nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
@@ -238,6 +241,7 @@ org_settings:
org_logo_url_light_background: ""
org_name: ${ORG_NAME}
secrets:
software:
`,
)
require.NoError(t, err)
@@ -381,6 +385,7 @@ agent_options:
name: ${TEST_TEAM_NAME}
team_settings:
secrets: ${TEST_SECRET}
software:
`,
)
require.NoError(t, err)
@@ -538,6 +543,7 @@ func TestFullGlobalGitOps(t *testing.T) {
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName)
t.Setenv("SOFTWARE_INSTALLER_URL", fleetServerURL)
file := "./testdata/gitops/global_config_no_paths.yml"
// Dry run should fail because Apple BM Default Team does not exist and premium license is not set
@@ -834,6 +840,7 @@ agent_options:
name: ${TEST_TEAM_NAME}
team_settings:
secrets: [{"secret":"${TEST_SECRET}"}]
software:
`,
)
require.NoError(t, err)
@@ -1011,6 +1018,7 @@ org_settings:
org_logo_url_light_background: ""
org_name: ${ORG_NAME}
secrets: [{"secret":"globalSecret"}]
software:
`,
)
require.NoError(t, err)
@@ -1030,6 +1038,7 @@ agent_options:
name: ${TEST_TEAM_NAME}
team_settings:
secrets: [{"secret":"${TEST_SECRET}"}]
software:
`,
)
require.NoError(t, err)
@@ -1045,6 +1054,7 @@ agent_options:
name: ${TEST_TEAM_NAME}
team_settings:
secrets: [{"secret":"${TEST_SECRET}"},{"secret":"globalSecret"}]
software:
`,
)
require.NoError(t, err)
@@ -1222,7 +1232,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) {
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
@@ -1255,6 +1265,39 @@ func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) {
require.NoError(t, err)
}
func TestNoTeamSoftwareInstallersGitOps(t *testing.T) {
startSoftwareInstallerServer(t)
cases := []struct {
file string
wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."},
{"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
{"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 500 MB"},
{"testdata/gitops/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
{"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
setupFullGitOpsPremiumServer(t)
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func TestTeamVPPAppsGitOps(t *testing.T) {
config := &appleVPPConfigSrvConf{
Assets: []vpp.Asset{
+1 -1
View File
@@ -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, fleet.ApplyClientSpecOptions{})
_, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{})
if err != nil {
return err
}
@@ -187,3 +187,4 @@ org_settings:
secrets: # These secrets are used to enroll hosts to the "All teams" team
- secret: SampleSecret123
- secret: ABC
software:
@@ -91,3 +91,4 @@ org_settings:
databases_path: ""
secrets:
- secret: ABC
software:
@@ -97,3 +97,4 @@ org_settings:
databases_path: ""
secrets:
- secret: ABC
software:
@@ -93,3 +93,4 @@ org_settings:
databases_path: ""
secrets:
- secret: ABC
software:
@@ -91,3 +91,4 @@ org_settings:
databases_path: ""
secrets:
- secret: ABC
software:
@@ -0,0 +1,19 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/notfound.sh
@@ -0,0 +1,18 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
self_service: "not a boolean"
@@ -0,0 +1,22 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
@@ -0,0 +1,17 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
@@ -0,0 +1,21 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
post_install_script:
path: lib/notfound.sh
@@ -0,0 +1,23 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_multiple.yml
post_install_script:
path: lib/post_install_ruby.sh
@@ -0,0 +1,21 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/notfound.yml
@@ -0,0 +1,17 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
@@ -0,0 +1,17 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
@@ -0,0 +1,25 @@
# Test config
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: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true
+2 -2
View File
@@ -2957,8 +2957,8 @@ _Available in Fleet Premium._
| Name | Type | In | Description |
| --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. |
| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. |
| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. Ommitting these parameters will add software to 'No Team'. |
| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. Ommitting these parameters will add software to 'No Team'. |
| dry_run | bool | query | If `true`, will validate the provided software packages and return any validation errors, but will not apply the changes. |
| software | list | body | An array of software objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, and `post_install_script` - script that runs after software install. |
+13 -14
View File
@@ -580,25 +580,24 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
const maxInstallerSizeBytes int64 = 1024 * 1024 * 500
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error {
if tmName == "" {
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty"))
}
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return err
}
tm, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return nil
var teamID *uint
if tmName != "" {
tm, err := svc.ds.TeamByName(ctx, tmName)
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
}
return err
teamID = &tm.ID
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &tm.ID}, fleet.ActionWrite); err != nil {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err, "validating authorization")
}
@@ -672,7 +671,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin
}
installer := &fleet.UploadSoftwareInstallerPayload{
TeamID: &tm.ID,
TeamID: teamID,
InstallScript: p.InstallScript,
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
@@ -732,7 +731,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin
}
}
if err := svc.ds.BatchSetSoftwareInstallers(ctx, &tm.ID, installers); err != nil {
if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, installers); err != nil {
return ctxerr.Wrap(ctx, err, "batch set software installers")
}
+1 -1
View File
@@ -1246,7 +1246,7 @@ func (svc *Service) editTeamFromSpec(
if spec.Software != nil {
if team.Config.Software == nil {
team.Config.Software = &fleet.TeamSpecSoftware{}
team.Config.Software = &fleet.SoftwareSpec{}
}
if spec.Software.Packages.Set {
+15 -9
View File
@@ -61,12 +61,12 @@ type GitOps struct {
}
type GitOpsSoftware struct {
Packages []*fleet.TeamSpecSoftwarePackage
Packages []*fleet.SoftwarePackageSpec
AppStoreApps []*fleet.TeamSpecAppStoreApp
}
// GitOpsFromFile parses a GitOps yaml file.
func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) {
func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig) (*GitOps, error) {
b, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err)
@@ -96,18 +96,16 @@ func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) {
// Figure out if this is an org or team settings file
teamRaw, teamOk := top["name"]
teamSettingsRaw, teamSettingsOk := top["team_settings"]
teamSoftware, teamSoftwareOk := top["software"]
orgSettingsRaw, orgOk := top["org_settings"]
if orgOk {
if teamOk || teamSettingsOk || teamSoftwareOk {
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings' or 'software'"))
if teamOk || teamSettingsOk {
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings'"))
} else {
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
}
} else if teamOk && teamSettingsOk {
multiError = parseName(teamRaw, result, multiError)
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
multiError = parseSoftware(teamSoftware, result, baseDir, multiError)
} else {
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
}
@@ -118,6 +116,10 @@ func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) {
multiError = parsePolicies(top, result, baseDir, multiError)
multiError = parseQueries(top, result, baseDir, multiError)
if appConfig != nil && appConfig.License.IsPremium() {
multiError = parseSoftware(top, result, baseDir, multiError)
}
return result, multiError.ErrorOrNil()
}
@@ -523,11 +525,15 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string
return multiError
}
func parseSoftware(softwareRaw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var software fleet.TeamSpecSoftware
func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
softwareRaw, ok := top["software"]
if !ok {
return multierror.Append(multiError, errors.New("'software' is required"))
}
var software fleet.SoftwareSpec
if len(softwareRaw) > 0 {
if err := json.Unmarshal(softwareRaw, &software); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshall software: %v", err))
return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err))
}
}
if software.AppStoreApps.Set {
+8 -8
View File
@@ -53,7 +53,7 @@ func createTempFile(t *testing.T, pattern, contents string) (filePath string, ba
func gitOpsFromString(t *testing.T, s string) (*GitOps, error) {
path, basePath := createTempFile(t, "", s)
return GitOpsFromFile(path, basePath)
return GitOpsFromFile(path, basePath, nil)
}
func TestValidGitOpsYaml(t *testing.T) {
@@ -108,7 +108,7 @@ func TestValidGitOpsYaml(t *testing.T) {
t.Parallel()
}
gitops, err := GitOpsFromFile(test.filePath, "./testdata")
gitops, err := GitOpsFromFile(test.filePath, "./testdata", nil)
require.NoError(t, err)
if test.isTeam {
@@ -336,20 +336,20 @@ func TestMixingGlobalAndTeamConfig(t *testing.T) {
config := getGlobalConfig(nil)
config += "name: TeamName\n"
_, err := gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'")
// Mixing org_settings and team_settings
config = getGlobalConfig(nil)
config += "team_settings:\n secrets: []\n"
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', '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 = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'")
}
func TestInvalidGitOpsYaml(t *testing.T) {
@@ -696,7 +696,7 @@ func TestGitOpsPaths(t *testing.T) {
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
assert.NoError(t, err)
// Test a bad path
@@ -709,7 +709,7 @@ func TestGitOpsPaths(t *testing.T) {
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
assert.ErrorContains(t, err, "no such file or directory")
// Test a bad file -- cannot be unmarshalled
@@ -744,7 +744,7 @@ func TestGitOpsPaths(t *testing.T) {
}
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
assert.ErrorContains(t, err, "nested paths are not supported")
},
)
+1
View File
@@ -26,6 +26,7 @@ type Group struct {
Packs []*fleet.PackSpec
Labels []*fleet.LabelSpec
Policies []*fleet.PolicySpec
Software []*fleet.SoftwarePackageSpec
// This needs to be interface{} to allow for the patch logic. Otherwise we send a request that looks to the
// server like the user explicitly set the zero values.
AppConfig interface{}
+14
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/ptr"
)
@@ -334,6 +335,19 @@ type SoftwarePackageOrApp struct {
LastInstall *HostSoftwareInstall `json:"last_install"`
}
type SoftwarePackageSpec struct {
URL string `json:"url"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
}
type SoftwareSpec struct {
Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"`
AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"`
}
// HostSoftwareInstall represents installation of software on a host from a
// Fleet software installer.
type HostSoftwareInstall struct {
+2 -15
View File
@@ -155,7 +155,7 @@ type TeamConfig struct {
Features Features `json:"features"`
MDM TeamMDM `json:"mdm"`
Scripts optjson.Slice[string] `json:"scripts,omitempty"`
Software *TeamSpecSoftware `json:"software,omitempty"`
Software *SoftwareSpec `json:"software,omitempty"`
}
type TeamWebhookSettings struct {
@@ -168,23 +168,10 @@ type TeamSpecSoftwareAsset struct {
Path string `json:"path"`
}
type TeamSpecSoftware struct {
Packages optjson.Slice[TeamSpecSoftwarePackage] `json:"packages,omitempty"`
AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"`
}
type TeamSpecAppStoreApp struct {
AppStoreID string `json:"app_store_id"`
}
type TeamSpecSoftwarePackage struct {
URL string `json:"url"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
}
type TeamMDM struct {
EnableDiskEncryption bool `json:"enable_disk_encryption"`
MacOSUpdates AppleOSUpdateSettings `json:"macos_updates"`
@@ -450,7 +437,7 @@ type TeamSpec struct {
Scripts optjson.Slice[string] `json:"scripts"`
WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"`
Integrations TeamSpecIntegrations `json:"integrations"`
Software *TeamSpecSoftware `json:"software,omitempty"`
Software *SoftwareSpec `json:"software,omitempty"`
}
type TeamSpecWebhookSettings struct {
+133 -91
View File
@@ -394,6 +394,7 @@ func (c *Client) ApplyGroup(
specs *spec.Group,
baseDir string,
logf func(format string, args ...interface{}),
appconfig *fleet.EnrichedAppConfig,
opts fleet.ApplyClientSpecOptions,
) (map[string]uint, error) {
logfn := func(format string, args ...interface{}) {
@@ -610,90 +611,10 @@ func (c *Client) ApplyGroup(
tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams)
tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts))
for tmName, software := range tmSoftwarePackages {
softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(software))
for i, si := range software {
var qc string
var err error
if si.PreInstallQuery.Path != "" {
queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path)
rawSpec, err := os.ReadFile(queryFile)
if err != nil {
return nil, fmt.Errorf("reading pre-install query: %w", err)
}
rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec)
if err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
var top any
if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
if _, ok := top.(map[any]any); ok {
// Old apply format
group, err := spec.GroupFromBytes(rawSpecExpanded)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err)
}
if len(group.Queries) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(group.Queries) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = group.Queries[0].Query
} else {
// Gitops format
var querySpecs []fleet.QuerySpec
if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err)
}
if len(querySpecs) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(querySpecs) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = querySpecs[0].Query
}
}
var ic []byte
if si.InstallScript.Path != "" {
installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path)
ic, err = os.ReadFile(installScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err)
}
}
var pc []byte
if si.PostInstallScript.Path != "" {
postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path)
pc, err = os.ReadFile(postInstallScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err)
}
}
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: si.URL,
SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
PostInstallScript: string(pc),
}
softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software)
if err != nil {
return nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err)
}
tmSoftwarePackagesPayloads[tmName] = softwarePayloads
}
@@ -810,6 +731,95 @@ func (c *Client) ApplyGroup(
return teamIDsByName, nil
}
func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) {
softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs))
for i, si := range specs {
var qc string
var err error
if si.PreInstallQuery.Path != "" {
queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path)
rawSpec, err := os.ReadFile(queryFile)
if err != nil {
return nil, fmt.Errorf("reading pre-install query: %w", err)
}
rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec)
if err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
var top any
if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
if _, ok := top.(map[any]any); ok {
// Old apply format
group, err := spec.GroupFromBytes(rawSpecExpanded)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err)
}
if len(group.Queries) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(group.Queries) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = group.Queries[0].Query
} else {
// Gitops format
var querySpecs []fleet.QuerySpec
if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err)
}
if len(querySpecs) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(querySpecs) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = querySpecs[0].Query
}
}
var ic []byte
if si.InstallScript.Path != "" {
installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path)
ic, err = os.ReadFile(installScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err)
}
}
var pc []byte
if si.PostInstallScript.Path != "" {
postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path)
pc, err = os.ReadFile(postInstallScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err)
}
}
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: si.URL,
SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
PostInstallScript: string(pc),
}
}
return softwarePayloads, nil
}
func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
@@ -1045,8 +1055,8 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profi
return m
}
func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage {
var m map[string][]fleet.TeamSpecSoftwarePackage
func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.SoftwarePackageSpec {
var m map[string][]fleet.SoftwarePackageSpec
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
@@ -1059,10 +1069,10 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" && len(spec.Software) > 0 {
if m == nil {
m = make(map[string][]fleet.TeamSpecSoftwarePackage)
m = make(map[string][]fleet.SoftwarePackageSpec)
}
var software fleet.TeamSpecSoftware
var packages []fleet.TeamSpecSoftwarePackage
var software fleet.SoftwareSpec
var packages []fleet.SoftwarePackageSpec
if err := json.Unmarshal(spec.Software, &software); err != nil {
// ignore, will fail in apply team specs call
continue
@@ -1070,7 +1080,7 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee
if !software.Packages.Valid {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
packages = []fleet.TeamSpecSoftwarePackage{}
packages = []fleet.SoftwarePackageSpec{}
} else {
packages = software.Packages.Value
}
@@ -1096,7 +1106,7 @@ func extractTmSpecsSoftwareApps(tmSpecs []json.RawMessage) map[string][]fleet.Te
if m == nil {
m = make(map[string][]fleet.TeamSpecAppStoreApp)
}
var software fleet.TeamSpecSoftware
var software fleet.SoftwareSpec
var apps []fleet.TeamSpecAppStoreApp
if err := json.Unmarshal(spec.Software, &software); err != nil {
// ignore, will fail in apply team specs call
@@ -1255,6 +1265,8 @@ func (c *Client) DoGitOps(
}
}
group.AppConfig.(map[string]interface{})["scripts"] = scripts
group.Software = config.Software.Packages
} else {
team = make(map[string]interface{})
team["name"] = *config.TeamName
@@ -1413,7 +1425,7 @@ func (c *Client) DoGitOps(
}
// Apply org settings, scripts, enroll secrets, and controls
teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplyClientSpecOptions{
teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{
ApplySpecOptions: fleet.ApplySpecOptions{
DryRun: dryRun,
},
@@ -1449,9 +1461,39 @@ func (c *Client) DoGitOps(
return nil, err
}
err = c.doGitOpsNoTeamSoftware(group, baseDir, appConfig, logFn, dryRun)
if err != nil {
return nil, err
}
return teamAssumptions, nil
}
func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) error {
if len(specs.Teams) == 0 && appconfig != nil && appconfig.License.IsPremium() {
packages := make([]fleet.SoftwarePackageSpec, 0, len(specs.Software))
for _, software := range specs.Software {
if software != nil {
packages = append(packages, *software)
}
}
payload, err := buildSoftwarePackagesPayload(baseDir, packages)
if err != nil {
return fmt.Errorf("applying software installers: %w", err)
}
if err := c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}); err != nil {
return fmt.Errorf("applying software installers: %w", err)
}
if dryRun {
logFn("[+] would've applied 'No Team' software installers\n")
} else {
logFn("[+] applied 'No Team' software installers\n")
}
}
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)
+11
View File
@@ -1,6 +1,8 @@
package service
import (
"net/url"
"github.com/fleetdm/fleet/v4/server/fleet"
)
@@ -25,3 +27,12 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu
}
return responseBody.SoftwareTitles, nil
}
func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) error {
verb, path := "POST", "/api/latest/fleet/software/batch"
query, err := url.ParseQuery(opts.RawQuery())
if err != nil {
return err
}
return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode())
}
+37 -4
View File
@@ -10161,7 +10161,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
wantSoftwarePackages := []fleet.TeamSpecSoftwarePackage{
wantSoftwarePackages := []fleet.SoftwarePackageSpec{
{
URL: "http://foo.com",
SelfService: true,
@@ -10320,9 +10320,6 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
t := s.T()
// a team name is required (we don't allow installers for "no team")
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusBadRequest)
// non-existent team
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo")
@@ -10397,6 +10394,42 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID)))
require.Equal(t, 0, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 0)
//////////////////////////
// Do a request with a valid URL with no team
//////////////////////////
softwareToInstall = []fleet.SoftwareInstallerPayload{
{URL: srv.URL},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent)
// check the application status on team 0
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, 1, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 1)
// same payload doesn't modify anything
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent)
newTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, titlesResp, newTitlesResp)
// setting self-service to true updates the software title metadata
softwareToInstall[0].SelfService = true
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent)
newTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
require.Equal(t, titlesResp, newTitlesResp)
// empty payload cleans the software items
softwareToInstall = []fleet.SoftwareInstallerPayload{}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent)
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, 0, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 0)
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() {
+1 -1
View File
@@ -318,7 +318,7 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st
////////////////////////////////////////////////////////////////////////////////
type batchSetSoftwareInstallersRequest struct {
TeamName string `json:"-" query:"team_name"`
TeamName string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Software []fleet.SoftwareInstallerPayload `json:"software"`
}