diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index 751c74709f1..a84ade90f00 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -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 } diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index c56016eca32..6f13fd29e9d 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -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 } diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index ab3f8bf8356..7089ef68930 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -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}) } - } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 826dd43374a..22bd28845ac 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -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{ diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index 1aa78695f9f..e11dc8bf9e2 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -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 } diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 76936e3ad5a..7d3dfe40f24 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -187,3 +187,4 @@ org_settings: secrets: # These secrets are used to enroll hosts to the "All teams" team - secret: SampleSecret123 - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml index 177c1c80cff..b0984425851 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml index da75847cd59..e6231bf0309 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml @@ -97,3 +97,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml index 9d2ac6e69f6..1a100de6f36 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml @@ -93,3 +93,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml index ba1d06f7849..5208ba72486 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml new file mode 100644 index 00000000000..d3bcada54e8 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml @@ -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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml new file mode 100644 index 00000000000..acee06d683a --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml @@ -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" \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml new file mode 100644 index 00000000000..6d83a9daed5 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml @@ -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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml new file mode 100644 index 00000000000..cd7332f91e5 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.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}/notfound.deb \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml new file mode 100644 index 00000000000..ac0a436360c --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml @@ -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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml new file mode 100644 index 00000000000..a2b5419c056 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml @@ -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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml new file mode 100644 index 00000000000..bafde42691b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml @@ -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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml new file mode 100644 index 00000000000..db4ffd32113 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.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 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml new file mode 100644 index 00000000000..2bc609b931d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.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}/invalidtype.txt \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml new file mode 100644 index 00000000000..e0fcaa490ec --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml @@ -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 \ No newline at end of file diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 170ff1c8ea6..d0ebb5875fb 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -2956,8 +2956,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. | diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 9871f9fd44a..ad0dbe0a816 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -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") } diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 4b8df031237..3d25abafe9e 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -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 { diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index b6eea43708b..e29e9138dcd 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -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 { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 644c3e453e2..58e12553b98 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -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") }, ) diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index d760f8b1d3c..10bafd76daa 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -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{} diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index d5428409899..70a8f2ca36b 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -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 { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d5eaa71f8b5..fa9734f6c19 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -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 { diff --git a/server/service/client.go b/server/service/client.go index 09ab08e0319..740ac195786 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -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) diff --git a/server/service/client_software.go b/server/service/client_software.go index 22c602e96c0..d08faee4040 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -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()) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index f87d9b144ab..e38a7d6b884 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -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() { diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 5de426c2d68..0cdff35c2a5 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -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"` }