mirror of
https://github.com/fleetdm/fleet
synced 2026-05-21 16:08:47 +00:00
Feature: Scope Fleet-maintained apps and custom packages via labels (#24976)
Issue #22813 # 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. --> - [x] 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] 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
This commit is contained in:
commit
38fcc30b5c
91 changed files with 4528 additions and 646 deletions
1
changes/22813-software-scope-labels
Normal file
1
changes/22813-software-scope-labels
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added features to scope Fleet-maintained apps and custom packages via labels in UI, API, and CLI.
|
||||
1
changes/24533-skip-policy
Normal file
1
changes/24533-skip-policy
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds functionality for skipping automatic installs if the software is not scoped to the host via labels.
|
||||
1
changes/24534-hide-software-2
Normal file
1
changes/24534-hide-software-2
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Add functionality to filter host software based on label scoping.
|
||||
|
|
@ -0,0 +1 @@
|
|||
* Added a validation to prevent label deletion if it is used to scope the hosts targeted by a software installer.
|
||||
1
changes/24538-24542-UI-for-scope-software-via-labels
Normal file
1
changes/24538-24542-UI-for-scope-software-via-labels
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add UI for scoping software via labels
|
||||
1
changes/24663-software-scoped-via-labels-gitops
Normal file
1
changes/24663-software-scoped-via-labels-gitops
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added `fleetctl gitops` support to scope software installers by labels, with the `labels_include_any` or `labels_exclude_any` conditions.
|
||||
1
changes/24792-update-software-installer-activities
Normal file
1
changes/24792-update-software-installer-activities
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added the `labels_include_any` and `labels_exclude_any` fields to the software installer activities.
|
||||
|
|
@ -1910,6 +1910,10 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
|
|||
{"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", "\"packages.self_service\" must be a bool, found string"},
|
||||
{"testdata/gitops/team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`},
|
||||
{"testdata/gitops/team_software_installer_valid_include.yml", ""},
|
||||
{"testdata/gitops/team_software_installer_valid_exclude.yml", ""},
|
||||
{"testdata/gitops/team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"},
|
||||
// team tests for setup experience software/script
|
||||
{"testdata/gitops/team_setup_software_valid.yml", ""},
|
||||
{"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
|
||||
|
|
@ -1939,6 +1943,22 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
|
|||
Teams: nil,
|
||||
}, nil
|
||||
}
|
||||
labelToIDs := map[string]uint{
|
||||
fleet.BuiltinLabelMacOS14Plus: 1,
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
}
|
||||
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
|
||||
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
|
||||
ret := make(map[string]uint)
|
||||
for _, lbl := range labels {
|
||||
id, ok := labelToIDs[lbl]
|
||||
if ok {
|
||||
ret[lbl] = id
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", c.file})
|
||||
if c.wantErr == "" {
|
||||
|
|
@ -1992,6 +2012,10 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
|
|||
{"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", "\"packages.self_service\" must be a bool, found string"},
|
||||
{"testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`},
|
||||
{"testdata/gitops/no_team_software_installer_valid_include.yml", ""},
|
||||
{"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""},
|
||||
{"testdata/gitops/no_team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"},
|
||||
// No team tests for setup experience software/script
|
||||
{"testdata/gitops/no_team_setup_software_valid.yml", ""},
|
||||
{"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
|
||||
|
|
@ -2021,6 +2045,22 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
|
|||
Teams: nil,
|
||||
}, nil
|
||||
}
|
||||
labelToIDs := map[string]uint{
|
||||
fleet.BuiltinLabelMacOS14Plus: 1,
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
}
|
||||
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
|
||||
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
|
||||
ret := make(map[string]uint)
|
||||
for _, lbl := range labels {
|
||||
id, ok := labelToIDs[lbl]
|
||||
if ok {
|
||||
ret[lbl] = id
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
|
||||
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
||||
|
|
|
|||
20
cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml
vendored
Normal file
20
cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: No team
|
||||
controls:
|
||||
policies:
|
||||
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
|
||||
uninstall_script:
|
||||
path: lib/uninstall_ruby.sh
|
||||
labels_include_any:
|
||||
- a
|
||||
labels_exclude_any:
|
||||
- b
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
18
cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_unknown_label.yml
vendored
Normal file
18
cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_unknown_label.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: No team
|
||||
controls:
|
||||
policies:
|
||||
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
|
||||
uninstall_script:
|
||||
path: lib/uninstall_ruby.sh
|
||||
labels_exclude_any:
|
||||
- zzz
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
19
cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_exclude.yml
vendored
Normal file
19
cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_exclude.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: No team
|
||||
controls:
|
||||
policies:
|
||||
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
|
||||
uninstall_script:
|
||||
path: lib/uninstall_ruby.sh
|
||||
labels_exclude_any:
|
||||
- a
|
||||
- b
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
19
cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_include.yml
vendored
Normal file
19
cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_include.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: No team
|
||||
controls:
|
||||
policies:
|
||||
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
|
||||
uninstall_script:
|
||||
path: lib/uninstall_ruby.sh
|
||||
labels_include_any:
|
||||
- a
|
||||
- b
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
29
cmd/fleetctl/testdata/gitops/team_software_installer_invalid_both_include_exclude.yml
vendored
Normal file
29
cmd/fleetctl/testdata/gitops/team_software_installer_invalid_both_include_exclude.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby_apply.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
labels_include_any:
|
||||
- a
|
||||
labels_exclude_any:
|
||||
- b
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
27
cmd/fleetctl/testdata/gitops/team_software_installer_invalid_unknown_label.yml
vendored
Normal file
27
cmd/fleetctl/testdata/gitops/team_software_installer_invalid_unknown_label.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby_apply.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
labels_include_any:
|
||||
- zzz
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
27
cmd/fleetctl/testdata/gitops/team_software_installer_valid_exclude.yml
vendored
Normal file
27
cmd/fleetctl/testdata/gitops/team_software_installer_valid_exclude.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby_apply.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
labels_exclude_any:
|
||||
- b
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
27
cmd/fleetctl/testdata/gitops/team_software_installer_valid_include.yml
vendored
Normal file
27
cmd/fleetctl/testdata/gitops/team_software_installer_valid_include.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby_apply.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
labels_include_any:
|
||||
- a
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||
self_service: true
|
||||
|
|
@ -1242,6 +1242,8 @@ This activity contains the following fields:
|
|||
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
|
||||
- "self_service": Whether the software is available for installation by the end user.
|
||||
- "software_title_id": ID of the added software title.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.
|
||||
|
||||
#### Example
|
||||
|
||||
|
|
@ -1252,7 +1254,17 @@ This activity contains the following fields:
|
|||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true,
|
||||
"software_title_id": 2234
|
||||
"software_title_id": 2234,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1266,6 +1278,8 @@ This activity contains the following fields:
|
|||
- "team_name": Name of the team on which this software was updated. `null` if it was updated on no team.
|
||||
- "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team.
|
||||
- "self_service": Whether the software is available for installation by the end user.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.
|
||||
|
||||
#### Example
|
||||
|
||||
|
|
@ -1275,7 +1289,17 @@ This activity contains the following fields:
|
|||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true
|
||||
"self_service": true,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1289,6 +1313,8 @@ This activity contains the following fields:
|
|||
- "team_name": Name of the team to which this software was added. `null` if it was added to no team.
|
||||
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
|
||||
- "self_service": Whether the software was available for installation by the end user.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.
|
||||
|
||||
#### Example
|
||||
|
||||
|
|
@ -1298,7 +1324,17 @@ This activity contains the following fields:
|
|||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true
|
||||
"self_service": true,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
appID uint,
|
||||
installScript, preInstallQuery, postInstallScript, uninstallScript string,
|
||||
selfService bool,
|
||||
labelsIncludeAny, labelsExcludeAny []string,
|
||||
) (titleID uint, err error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return 0, err
|
||||
|
|
@ -36,6 +37,12 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
return 0, fleet.ErrNoContext
|
||||
}
|
||||
|
||||
// validate labels before we do anything else
|
||||
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny)
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "validating software labels")
|
||||
}
|
||||
|
||||
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
|
||||
}
|
||||
|
|
@ -119,6 +126,7 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
SelfService: selfService,
|
||||
InstallScript: installScript,
|
||||
UninstallScript: uninstallScript,
|
||||
ValidatedLabels: validatedLabels,
|
||||
}
|
||||
|
||||
// Create record in software installers table
|
||||
|
|
@ -142,13 +150,16 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
teamName = &t.Name
|
||||
}
|
||||
|
||||
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
|
||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
||||
SoftwareTitle: payload.Title,
|
||||
SoftwarePackage: payload.Filename,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
SelfService: payload.SelfService,
|
||||
SoftwareTitleID: titleID,
|
||||
SoftwareTitle: payload.Title,
|
||||
SoftwarePackage: payload.Filename,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
SelfService: payload.SelfService,
|
||||
SoftwareTitleID: titleID,
|
||||
LabelsIncludeAny: actLabelsIncl,
|
||||
LabelsExcludeAny: actLabelsExcl,
|
||||
}); err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/go-kit/log"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
|
|
@ -37,6 +37,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
return err
|
||||
}
|
||||
|
||||
// validate labels before we do anything else
|
||||
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating software labels")
|
||||
}
|
||||
payload.ValidatedLabels = validatedLabels
|
||||
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return fleet.ErrNoContext
|
||||
|
|
@ -86,13 +93,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
}
|
||||
|
||||
// Create activity
|
||||
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
|
||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
||||
SoftwareTitle: payload.Title,
|
||||
SoftwarePackage: payload.Filename,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
SelfService: payload.SelfService,
|
||||
SoftwareTitleID: titleID,
|
||||
SoftwareTitle: payload.Title,
|
||||
SoftwarePackage: payload.Filename,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
SelfService: payload.SelfService,
|
||||
SoftwareTitleID: titleID,
|
||||
LabelsIncludeAny: actLabelsIncl,
|
||||
LabelsExcludeAny: actLabelsExcl,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "creating activity for added software")
|
||||
}
|
||||
|
|
@ -100,6 +110,42 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
return nil
|
||||
}
|
||||
|
||||
func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) {
|
||||
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
|
||||
return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context")
|
||||
} else if !authctx.Checked() {
|
||||
return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization")
|
||||
}
|
||||
|
||||
var names []string
|
||||
var scope fleet.LabelScope
|
||||
switch {
|
||||
case len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0:
|
||||
return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`}
|
||||
case len(labelsIncludeAny) > 0:
|
||||
names = labelsIncludeAny
|
||||
scope = fleet.LabelScopeIncludeAny
|
||||
case len(labelsExcludeAny) > 0:
|
||||
names = labelsExcludeAny
|
||||
scope = fleet.LabelScopeExcludeAny
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
// nothing to validate, return empty result
|
||||
return &fleet.LabelIdentsWithScope{}, nil
|
||||
}
|
||||
|
||||
byName, err := svc.BatchValidateLabels(ctx, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fleet.LabelIdentsWithScope{
|
||||
LabelScope: scope,
|
||||
ByName: byName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P<suffix>\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`)
|
||||
|
||||
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
|
||||
|
|
@ -141,7 +187,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
|
||||
var teamName *string
|
||||
if *payload.TeamID != 0 {
|
||||
t, err := svc.ds.Team(ctx, *payload.TeamID)
|
||||
t, err := svc.ds.TeamWithoutExtras(ctx, *payload.TeamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -186,7 +232,8 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
}
|
||||
|
||||
if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil &&
|
||||
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil {
|
||||
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil &&
|
||||
payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil {
|
||||
return existingInstaller, nil // no payload, noop
|
||||
}
|
||||
|
||||
|
|
@ -197,10 +244,24 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
dirty["SelfService"] = true
|
||||
}
|
||||
|
||||
shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "validating software labels for update")
|
||||
}
|
||||
if shouldUpdateLabels {
|
||||
dirty["Labels"] = true
|
||||
}
|
||||
payload.ValidatedLabels = validatedLabels
|
||||
|
||||
// activity team ID must be null if no team, not zero
|
||||
var actTeamID *uint
|
||||
if payload.TeamID != nil && *payload.TeamID != 0 {
|
||||
actTeamID = payload.TeamID
|
||||
}
|
||||
activity := fleet.ActivityTypeEditedSoftware{
|
||||
SoftwareTitle: existingInstaller.SoftwareTitle,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
TeamID: actTeamID,
|
||||
SelfService: existingInstaller.SelfService,
|
||||
SoftwarePackage: &existingInstaller.Name,
|
||||
}
|
||||
|
|
@ -343,6 +404,18 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
}
|
||||
}
|
||||
|
||||
// now that the payload has been updated with any patches, we can set the
|
||||
// final fields of the activity
|
||||
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(
|
||||
existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny)
|
||||
if payload.ValidatedLabels != nil {
|
||||
actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
|
||||
}
|
||||
activity.LabelsIncludeAny = actLabelsIncl
|
||||
activity.LabelsExcludeAny = actLabelsExcl
|
||||
if payload.SelfService != nil {
|
||||
activity.SelfService = *payload.SelfService
|
||||
}
|
||||
if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
|
||||
}
|
||||
|
|
@ -363,6 +436,69 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
return updatedInstaller, nil
|
||||
}
|
||||
|
||||
func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) {
|
||||
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
|
||||
return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
|
||||
} else if !authctx.Checked() {
|
||||
return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
|
||||
}
|
||||
|
||||
if existingInstaller == nil {
|
||||
return false, nil, errors.New("existing installer must be provided")
|
||||
}
|
||||
|
||||
if len(existingInstaller.LabelsIncludeAny) > 0 && len(existingInstaller.LabelsExcludeAny) > 0 {
|
||||
return false, nil, errors.New("existing installer must have only one label scope")
|
||||
}
|
||||
|
||||
if includeAny == nil && excludeAny == nil {
|
||||
// nothing to do
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
incoming, err := ValidateSoftwareLabels(ctx, svc, includeAny, excludeAny)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
var prevScope fleet.LabelScope
|
||||
var prevLabels []fleet.SoftwareScopeLabel
|
||||
switch {
|
||||
case len(existingInstaller.LabelsIncludeAny) > 0:
|
||||
prevScope = fleet.LabelScopeIncludeAny
|
||||
prevLabels = existingInstaller.LabelsIncludeAny
|
||||
case len(existingInstaller.LabelsExcludeAny) > 0:
|
||||
prevScope = fleet.LabelScopeExcludeAny
|
||||
prevLabels = existingInstaller.LabelsExcludeAny
|
||||
}
|
||||
|
||||
prevByName := make(map[string]fleet.LabelIdent, len(prevLabels))
|
||||
for _, pl := range prevLabels {
|
||||
prevByName[pl.LabelName] = fleet.LabelIdent{
|
||||
LabelID: pl.LabelID,
|
||||
LabelName: pl.LabelName,
|
||||
}
|
||||
}
|
||||
|
||||
if prevScope != incoming.LabelScope {
|
||||
return true, incoming, nil
|
||||
}
|
||||
|
||||
if len(prevByName) != len(incoming.ByName) {
|
||||
return true, incoming, nil
|
||||
}
|
||||
|
||||
// compare labels by name
|
||||
for n, il := range incoming.ByName {
|
||||
pl, ok := prevByName[n]
|
||||
if !ok || pl != il {
|
||||
return true, incoming, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
|
||||
if teamID == nil {
|
||||
return fleet.NewInvalidArgumentError("team_id", "is required")
|
||||
|
|
@ -441,20 +577,15 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof
|
|||
teamName = &t.Name
|
||||
}
|
||||
|
||||
var teamID *uint
|
||||
switch {
|
||||
case meta.TeamID == nil:
|
||||
teamID = ptr.Uint(0)
|
||||
case meta.TeamID != nil:
|
||||
teamID = meta.TeamID
|
||||
}
|
||||
|
||||
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny)
|
||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
|
||||
SoftwareTitle: meta.SoftwareTitle,
|
||||
SoftwarePackage: meta.Name,
|
||||
TeamName: teamName,
|
||||
TeamID: teamID,
|
||||
SelfService: meta.SelfService,
|
||||
SoftwareTitle: meta.SoftwareTitle,
|
||||
SoftwarePackage: meta.Name,
|
||||
TeamName: teamName,
|
||||
TeamID: meta.TeamID,
|
||||
SelfService: meta.SelfService,
|
||||
LabelsIncludeAny: actLabelsIncl,
|
||||
LabelsExcludeAny: actLabelsExcl,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
|
||||
}
|
||||
|
|
@ -670,6 +801,18 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
|
|||
|
||||
// if we found an installer, use that
|
||||
if installer != nil {
|
||||
// check the label scoping for this installer and host
|
||||
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, hostID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
|
||||
}
|
||||
|
||||
if !scoped {
|
||||
return &fleet.BadRequestError{
|
||||
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
|
||||
}
|
||||
}
|
||||
|
||||
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
|
||||
|
|
@ -1141,7 +1284,7 @@ const (
|
|||
)
|
||||
|
||||
func (svc *Service) BatchSetSoftwareInstallers(
|
||||
ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool,
|
||||
ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool,
|
||||
) (string, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
||||
return "", err
|
||||
|
|
@ -1185,6 +1328,12 @@ func (svc *Service) BatchSetSoftwareInstallers(
|
|||
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL),
|
||||
)
|
||||
}
|
||||
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload.ValidatedLabels = validatedLabels
|
||||
|
||||
allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
|
||||
}
|
||||
|
||||
|
|
@ -1229,7 +1378,7 @@ func (svc *Service) softwareBatchUpload(
|
|||
requestUUID string,
|
||||
teamID *uint,
|
||||
userID uint,
|
||||
payloads []fleet.SoftwareInstallerPayload,
|
||||
payloads []*fleet.SoftwareInstallerPayload,
|
||||
dryRun bool,
|
||||
) {
|
||||
var batchErr error
|
||||
|
|
@ -1341,6 +1490,9 @@ func (svc *Service) softwareBatchUpload(
|
|||
UserID: userID,
|
||||
URL: p.URL,
|
||||
InstallDuringSetup: p.InstallDuringSetup,
|
||||
LabelsIncludeAny: p.LabelsIncludeAny,
|
||||
LabelsExcludeAny: p.LabelsExcludeAny,
|
||||
ValidatedLabels: p.ValidatedLabels,
|
||||
}
|
||||
|
||||
// set the filename before adding metadata, as it is used as fallback
|
||||
|
|
@ -1497,6 +1649,17 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f
|
|||
}
|
||||
}
|
||||
|
||||
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, host.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
|
||||
}
|
||||
|
||||
if !scoped {
|
||||
return &fleet.BadRequestError{
|
||||
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
|
||||
}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(installer.Name)
|
||||
requiredPlatform := packageExtensionToPlatform(ext)
|
||||
if requiredPlatform == "" {
|
||||
|
|
@ -1642,3 +1805,40 @@ func UninstallSoftwareMigration(
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (include, exclude []fleet.ActivitySoftwareLabel) {
|
||||
if validatedLabels == nil || len(validatedLabels.ByName) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
excludeAny := validatedLabels.LabelScope == fleet.LabelScopeExcludeAny
|
||||
labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName))
|
||||
for _, lbl := range validatedLabels.ByName {
|
||||
labels = append(labels, fleet.ActivitySoftwareLabel{
|
||||
ID: lbl.LabelID,
|
||||
Name: lbl.LabelName,
|
||||
})
|
||||
}
|
||||
if excludeAny {
|
||||
exclude = labels
|
||||
} else {
|
||||
include = labels
|
||||
}
|
||||
return include, exclude
|
||||
}
|
||||
|
||||
func activitySoftwareLabelsFromSoftwareScopeLabels(includeScopeLabels, excludeScopeLabels []fleet.SoftwareScopeLabel) (include, exclude []fleet.ActivitySoftwareLabel) {
|
||||
for _, label := range includeScopeLabels {
|
||||
include = append(include, fleet.ActivitySoftwareLabel{
|
||||
ID: label.LabelID,
|
||||
Name: label.LabelName,
|
||||
})
|
||||
}
|
||||
for _, label := range excludeScopeLabels {
|
||||
exclude = append(exclude, fleet.ActivitySoftwareLabel{
|
||||
ID: label.LabelID,
|
||||
Name: label.LabelName,
|
||||
})
|
||||
}
|
||||
return include, exclude
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
|
||||
func TestPreProcessUninstallScript(t *testing.T) {
|
||||
t.Parallel()
|
||||
var input = `
|
||||
input := `
|
||||
blah$PACKAGE_IDS
|
||||
pkgids=$PACKAGE_ID
|
||||
they are $PACKAGE_ID, right $MY_SECRET?
|
||||
|
|
@ -74,7 +74,6 @@ quotes and braces for (
|
|||
"com.bar"
|
||||
)`
|
||||
assert.Equal(t, expected, payload.UninstallScript)
|
||||
|
||||
}
|
||||
|
||||
func TestInstallUninstallAuth(t *testing.T) {
|
||||
|
|
@ -93,7 +92,8 @@ func TestInstallUninstallAuth(t *testing.T) {
|
|||
}, nil
|
||||
}
|
||||
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
|
||||
withScriptContents bool) (*fleet.SoftwareInstaller, error) {
|
||||
withScriptContents bool,
|
||||
) (*fleet.SoftwareInstaller, error) {
|
||||
return &fleet.SoftwareInstaller{
|
||||
Name: "installer.pkg",
|
||||
Platform: "darwin",
|
||||
|
|
@ -104,14 +104,16 @@ func TestInstallUninstallAuth(t *testing.T) {
|
|||
return nil, nil
|
||||
}
|
||||
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool, policyID *uint) (string,
|
||||
error) {
|
||||
error,
|
||||
) {
|
||||
return "request_id", nil
|
||||
}
|
||||
ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
|
||||
return []byte("script"), nil
|
||||
}
|
||||
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
|
||||
error) {
|
||||
error,
|
||||
) {
|
||||
return &fleet.HostScriptResult{
|
||||
ExecutionID: "execution_id",
|
||||
}, nil
|
||||
|
|
@ -120,6 +122,10 @@ func TestInstallUninstallAuth(t *testing.T) {
|
|||
return nil
|
||||
}
|
||||
|
||||
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *fleet.User
|
||||
|
|
@ -197,7 +203,6 @@ func TestUninstallSoftwareTitle(t *testing.T) {
|
|||
// Host scripts disabled
|
||||
host.ScriptsEnabled = ptr.Bool(false)
|
||||
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
|
||||
|
||||
}
|
||||
|
||||
func checkAuthErr(t *testing.T, shouldFail bool, err error) {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
|
|||
version: "1.2.3",
|
||||
uploaded_at: "2020-01-01T00:00:00.000Z",
|
||||
install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /",
|
||||
uninstall_script: "sudo rm -rf /Applications/Falcon.app",
|
||||
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
|
||||
post_install_script:
|
||||
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
||||
|
|
@ -216,6 +217,8 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
|
|||
last_install: null,
|
||||
last_uninstall: null,
|
||||
package_url: "",
|
||||
labels_include_any: null,
|
||||
labels_exclude_any: null,
|
||||
};
|
||||
|
||||
export const createMockSoftwarePackage = (
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const PlatformSelector = ({
|
|||
|
||||
return (
|
||||
<div className={`${parentClass}__${baseClass} ${baseClass} form-field`}>
|
||||
<span className={labelClasses}>Checks on:</span>
|
||||
<span className={labelClasses}>Targets:</span>
|
||||
<span className={`${baseClass}__checkboxes`}>
|
||||
<Checkbox
|
||||
value={checkDarwin}
|
||||
|
|
@ -71,7 +71,8 @@ export const PlatformSelector = ({
|
|||
</Checkbox>
|
||||
</span>
|
||||
<div className="form-field__help-text">
|
||||
Your policy will only be checked on the selected platform(s).
|
||||
To apply the profile to new hosts, you'll have to delete it and
|
||||
upload a new profile.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,4 +11,8 @@
|
|||
.form-field__label--disabled {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
|
||||
&__platform-checkbox-wrapper {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import TargetLabelSelector from "./TargetLabelSelector";
|
||||
|
||||
describe("TargetLabelSelector component", () => {
|
||||
it("renders the custom target selector when the target type is 'Custom'", () => {
|
||||
render(
|
||||
<TargetLabelSelector
|
||||
selectedTargetType="Custom"
|
||||
selectedCustomTarget="labelIncludeAny"
|
||||
customTargetOptions={[
|
||||
{ value: "labelIncludeAny", label: "Include any" },
|
||||
]}
|
||||
selectedLabels={{}}
|
||||
labels={[
|
||||
{ id: 1, name: "label 1", label_type: "regular" },
|
||||
{ id: 2, name: "label 2", label_type: "regular" },
|
||||
]}
|
||||
onSelectCustomTarget={noop}
|
||||
onSelectLabel={noop}
|
||||
onSelectTargetType={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// custom target selector is rendering
|
||||
expect(screen.getByRole("option", { name: "Include any" })).toBeVisible();
|
||||
|
||||
// lables are rendering
|
||||
expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible();
|
||||
expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the custom target selector when the target type is 'All hosts'", () => {
|
||||
render(
|
||||
<TargetLabelSelector
|
||||
selectedTargetType="All hosts"
|
||||
selectedCustomTarget="labelIncludeAny"
|
||||
customTargetOptions={[
|
||||
{ value: "labelIncludeAny", label: "Include any" },
|
||||
]}
|
||||
selectedLabels={{}}
|
||||
labels={[
|
||||
{ id: 1, name: "label 1", label_type: "regular" },
|
||||
{ id: 2, name: "label 2", label_type: "regular" },
|
||||
]}
|
||||
onSelectCustomTarget={noop}
|
||||
onSelectLabel={noop}
|
||||
onSelectTargetType={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// custom target selector is not rendering
|
||||
expect(screen.queryByRole("option", { name: "Include any" })).toBeNull();
|
||||
|
||||
// lables are not rendering
|
||||
expect(screen.queryByRole("checkbox", { name: "label 1" })).toBeNull();
|
||||
expect(screen.queryByRole("checkbox", { name: "label 2" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders selected labels as checked", () => {
|
||||
render(
|
||||
<TargetLabelSelector
|
||||
selectedTargetType="Custom"
|
||||
selectedCustomTarget="labelIncludeAny"
|
||||
customTargetOptions={[
|
||||
{ value: "labelIncludeAny", label: "Include any" },
|
||||
]}
|
||||
selectedLabels={{ "label 1": true, "label 2": false }}
|
||||
labels={[
|
||||
{ id: 1, name: "label 1", label_type: "regular" },
|
||||
{ id: 2, name: "label 2", label_type: "regular" },
|
||||
]}
|
||||
onSelectCustomTarget={noop}
|
||||
onSelectLabel={noop}
|
||||
onSelectTargetType={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// lables are rendering
|
||||
expect(screen.getByRole("checkbox", { name: "label 1" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "label 2" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
210
frontend/components/TargetLabelSelector/TargetLabelSelector.tsx
Normal file
210
frontend/components/TargetLabelSelector/TargetLabelSelector.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import classnames from "classnames";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
|
||||
const baseClass = "target-label-selector";
|
||||
|
||||
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
||||
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
|
||||
if (isSelected) {
|
||||
acc.push(labelName);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export const generateLabelKey = (
|
||||
target: string,
|
||||
customTargetOption: string,
|
||||
selectedLabels: Record<string, boolean>
|
||||
) => {
|
||||
if (target !== "Custom") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
|
||||
};
|
||||
};
|
||||
|
||||
interface ITargetChooserProps {
|
||||
selectedTarget: string;
|
||||
onSelect: (val: string) => void;
|
||||
}
|
||||
|
||||
const TargetChooser = ({ selectedTarget, onSelect }: ITargetChooserProps) => {
|
||||
return (
|
||||
<div className={`form-field`}>
|
||||
<div className="form-field__label">Target</div>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="All hosts"
|
||||
id="all-hosts-target-radio-btn"
|
||||
checked={selectedTarget === "All hosts"}
|
||||
value="All hosts"
|
||||
name="target-type"
|
||||
onChange={onSelect}
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Custom"
|
||||
id="custom-target-radio-btn"
|
||||
checked={selectedTarget === "Custom"}
|
||||
value="Custom"
|
||||
name="target-type"
|
||||
onChange={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILabelChooserProps {
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
labels: ILabelSummary[];
|
||||
selectedLabels: Record<string, boolean>;
|
||||
selectedCustomTarget: string;
|
||||
customTargetOptions: IDropdownOption[];
|
||||
dropdownHelpText?: ReactNode;
|
||||
onSelectCustomTarget: (val: string) => void;
|
||||
onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void;
|
||||
}
|
||||
|
||||
const LabelChooser = ({
|
||||
isError,
|
||||
isLoading,
|
||||
labels,
|
||||
dropdownHelpText,
|
||||
selectedLabels,
|
||||
selectedCustomTarget,
|
||||
customTargetOptions,
|
||||
onSelectCustomTarget,
|
||||
onSelectLabel,
|
||||
}: ILabelChooserProps) => {
|
||||
const getHelpText = (value: string) => {
|
||||
if (dropdownHelpText) return dropdownHelpText;
|
||||
return customTargetOptions.find((option) => option.value === value)
|
||||
?.helpText;
|
||||
};
|
||||
|
||||
const renderLabels = () => {
|
||||
if (isLoading) {
|
||||
return <Spinner centered={false} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
if (!labels.length) {
|
||||
return (
|
||||
<div className={`${baseClass}__no-labels`}>
|
||||
<span>
|
||||
<Link to={PATHS.LABEL_NEW_DYNAMIC}>Add labels</Link> to target
|
||||
specific hosts.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return labels.map((label) => {
|
||||
return (
|
||||
<div className={`${baseClass}__label`} key={label.name}>
|
||||
<Checkbox
|
||||
className={`${baseClass}__checkbox`}
|
||||
name={label.name}
|
||||
value={!!selectedLabels[label.name]}
|
||||
onChange={onSelectLabel}
|
||||
parseTarget
|
||||
/>
|
||||
<div className={`${baseClass}__label-name`}>{label.name}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__custom-label-chooser`}>
|
||||
<Dropdown
|
||||
value={selectedCustomTarget}
|
||||
options={customTargetOptions}
|
||||
searchable={false}
|
||||
onChange={onSelectCustomTarget}
|
||||
/>
|
||||
<div className={`${baseClass}__description`}>
|
||||
{getHelpText(selectedCustomTarget)}
|
||||
</div>
|
||||
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITargetLabelSelectorProps {
|
||||
selectedTargetType: string;
|
||||
selectedCustomTarget: string;
|
||||
customTargetOptions: IDropdownOption[];
|
||||
selectedLabels: Record<string, boolean>;
|
||||
labels: ILabelSummary[];
|
||||
/** set this prop to show a help text. If it is encluded then it will override
|
||||
* the selected options defined `helpText`
|
||||
*/
|
||||
dropdownHelpText?: ReactNode;
|
||||
isLoadingLabels?: boolean;
|
||||
isErrorLabels?: boolean;
|
||||
className?: string;
|
||||
onSelectTargetType: (val: string) => void;
|
||||
onSelectCustomTarget: (val: string) => void;
|
||||
onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void;
|
||||
}
|
||||
|
||||
const TargetLabelSelector = ({
|
||||
selectedTargetType,
|
||||
selectedCustomTarget,
|
||||
customTargetOptions,
|
||||
selectedLabels,
|
||||
dropdownHelpText,
|
||||
className,
|
||||
labels,
|
||||
isLoadingLabels = false,
|
||||
isErrorLabels = false,
|
||||
onSelectTargetType,
|
||||
onSelectCustomTarget,
|
||||
onSelectLabel,
|
||||
}: ITargetLabelSelectorProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<TargetChooser
|
||||
selectedTarget={selectedTargetType}
|
||||
onSelect={onSelectTargetType}
|
||||
/>
|
||||
{selectedTargetType === "Custom" && (
|
||||
<LabelChooser
|
||||
selectedCustomTarget={selectedCustomTarget}
|
||||
customTargetOptions={customTargetOptions}
|
||||
isError={isErrorLabels}
|
||||
isLoading={isLoadingLabels}
|
||||
labels={labels || []}
|
||||
selectedLabels={selectedLabels}
|
||||
dropdownHelpText={dropdownHelpText}
|
||||
onSelectCustomTarget={onSelectCustomTarget}
|
||||
onSelectLabel={onSelectLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TargetLabelSelector;
|
||||
57
frontend/components/TargetLabelSelector/_styles.scss
Normal file
57
frontend/components/TargetLabelSelector/_styles.scss
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
.target-label-selector {
|
||||
font-size: $x-small;
|
||||
|
||||
&__custom-label-chooser {
|
||||
margin-top: $pad-medium;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: $pad-medium 0;
|
||||
}
|
||||
|
||||
&__no-labels {
|
||||
display: flex;
|
||||
height: 187px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__checkboxes {
|
||||
display: flex;
|
||||
max-height: 187px;
|
||||
flex-direction: column;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-spinner {
|
||||
margin: 69.5px auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
width: 100%;
|
||||
padding: $pad-small $pad-medium;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
.form-field--checkbox {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label-name {
|
||||
padding-left: $pad-large;
|
||||
}
|
||||
}
|
||||
1
frontend/components/TargetLabelSelector/index.ts
Normal file
1
frontend/components/TargetLabelSelector/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./TargetLabelSelector";
|
||||
|
|
@ -126,6 +126,7 @@ const Checkbox = (props: ICheckboxProps) => {
|
|||
/>
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-label={name}
|
||||
aria-checked={indeterminate ? "mixed" : value || undefined}
|
||||
aria-readonly={readOnly}
|
||||
aria-disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ILabelSoftwareTitle } from "./label";
|
||||
import { Platform } from "./platform";
|
||||
import { IPolicy } from "./policy";
|
||||
import { IQuery } from "./query";
|
||||
|
|
@ -184,4 +185,7 @@ export interface IActivityDetails {
|
|||
app_store_id?: number;
|
||||
location?: string; // name of location associated with VPP token
|
||||
webhook_url?: string;
|
||||
software_title_id?: number;
|
||||
labels_include_any?: ILabelSoftwareTitle[];
|
||||
labels_exclude_any?: ILabelSoftwareTitle[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ export interface ILabelSummary {
|
|||
label_type: LabelType;
|
||||
}
|
||||
|
||||
export interface ILabelSoftwareTitle {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ILabel extends ILabelSummary {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import PropTypes from "prop-types";
|
|||
import { IconNames } from "components/icons";
|
||||
|
||||
import vulnerabilityInterface from "./vulnerability";
|
||||
import { ILabelSoftwareTitle } from "./label";
|
||||
|
||||
export default PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
|
|
@ -68,6 +69,7 @@ export interface ISoftwarePackage {
|
|||
version: string;
|
||||
uploaded_at: string;
|
||||
install_script: string;
|
||||
uninstall_script: string;
|
||||
pre_install_query?: string;
|
||||
post_install_script?: string;
|
||||
self_service: boolean;
|
||||
|
|
@ -81,6 +83,8 @@ export interface ISoftwarePackage {
|
|||
};
|
||||
automatic_install_policies?: ISoftwarePackagePolicy[];
|
||||
install_during_setup?: boolean;
|
||||
labels_include_any: ILabelSoftwareTitle[] | null;
|
||||
labels_exclude_any: ILabelSoftwareTitle[] | null;
|
||||
}
|
||||
|
||||
export const isSoftwarePackage = (
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDet
|
|||
import ActivityItem from "./ActivityItem";
|
||||
import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal";
|
||||
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
|
||||
import SoftwareDetailsModal from "./components/SoftwareDetailsModal";
|
||||
|
||||
const baseClass = "activity-feed";
|
||||
interface IActvityCardProps {
|
||||
|
|
@ -57,6 +58,11 @@ const ActivityFeed = ({
|
|||
activityAutomationDetails,
|
||||
setActivityAutomationDetails,
|
||||
] = useState<IActivityDetails | null>(null);
|
||||
const [
|
||||
softwareDetails,
|
||||
setSoftwareDetails,
|
||||
] = useState<IActivityDetails | null>(null);
|
||||
|
||||
const queryShown = useRef("");
|
||||
const queryImpact = useRef<string | undefined>(undefined);
|
||||
const scriptExecutionId = useRef("");
|
||||
|
|
@ -131,6 +137,11 @@ const ActivityFeed = ({
|
|||
case ActivityType.EditedActivityAutomations:
|
||||
setActivityAutomationDetails({ ...details });
|
||||
break;
|
||||
case ActivityType.AddedSoftware:
|
||||
case ActivityType.EditedSoftware:
|
||||
case ActivityType.DeletedSoftware:
|
||||
setSoftwareDetails({ ...details });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -245,6 +256,12 @@ const ActivityFeed = ({
|
|||
onCancel={() => setActivityAutomationDetails(null)}
|
||||
/>
|
||||
)}
|
||||
{softwareDetails && (
|
||||
<SoftwareDetailsModal
|
||||
details={softwareDetails}
|
||||
onCancel={() => setSoftwareDetails(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1160,9 +1160,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("added software ", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("foobar.pkg", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -1188,9 +1186,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("edited software", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(" on the ", {
|
||||
exact: false,
|
||||
|
|
@ -1213,9 +1209,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("deleted software ", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("foobar.pkg", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -1237,9 +1231,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("added software ", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("foobar.pkg", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -1258,9 +1250,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("edited software", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("on no team", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -1273,9 +1263,7 @@ describe("Activity Feed", () => {
|
|||
});
|
||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("deleted software ", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("foobar.pkg", { exact: false })
|
||||
).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -892,54 +892,129 @@ const TAGGED_TEMPLATES = {
|
|||
</>
|
||||
);
|
||||
},
|
||||
addedSoftware: (activity: IActivity) => {
|
||||
addedSoftware: (
|
||||
activity: IActivity,
|
||||
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
|
||||
) => {
|
||||
const {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
} = activity.details || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
added software <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) to{" "}
|
||||
added <b>{activity.details?.software_package}</b> to{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
)}{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(activity.type, {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
})
|
||||
}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
editedSoftware: (activity: IActivity) => {
|
||||
editedSoftware: (
|
||||
activity: IActivity,
|
||||
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
|
||||
) => {
|
||||
const {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
} = activity.details || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
edited software <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) on{" "}
|
||||
edited <b>{activity.details?.software_package}</b> on{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
)}{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(activity.type, {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
})
|
||||
}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
deletedSoftware: (activity: IActivity) => {
|
||||
deletedSoftware: (
|
||||
activity: IActivity,
|
||||
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
|
||||
) => {
|
||||
const {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
} = activity.details || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
deleted software <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) from{" "}
|
||||
deleted <b>{activity.details?.software_package}</b> from{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
)}{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(activity.type, {
|
||||
software_title,
|
||||
software_package,
|
||||
self_service,
|
||||
labels_include_any,
|
||||
labels_exclude_any,
|
||||
})
|
||||
}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
@ -1334,13 +1409,13 @@ const getDetail = (
|
|||
return TAGGED_TEMPLATES.resentConfigProfile(activity);
|
||||
}
|
||||
case ActivityType.AddedSoftware: {
|
||||
return TAGGED_TEMPLATES.addedSoftware(activity);
|
||||
return TAGGED_TEMPLATES.addedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
case ActivityType.EditedSoftware: {
|
||||
return TAGGED_TEMPLATES.editedSoftware(activity);
|
||||
return TAGGED_TEMPLATES.editedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
case ActivityType.DeletedSoftware: {
|
||||
return TAGGED_TEMPLATES.deletedSoftware(activity);
|
||||
return TAGGED_TEMPLATES.deletedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
case ActivityType.InstalledSoftware: {
|
||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React from "react";
|
||||
|
||||
import { IActivityDetails } from "interfaces/activity";
|
||||
import { ILabelSoftwareTitle } from "interfaces/label";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import DataSet from "components/DataSet";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
const baseClass = "software-details-modal";
|
||||
|
||||
interface ITargetValueProps {
|
||||
labels: ILabelSoftwareTitle[];
|
||||
}
|
||||
|
||||
const TargetValue = ({ labels }: ITargetValueProps) => {
|
||||
if (labels.length === 1) {
|
||||
return <>labels[0].name</>;
|
||||
}
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tipContent={labels.map((label) => (
|
||||
<>
|
||||
{label.name}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
>
|
||||
{labels.length} labels
|
||||
</TooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const generateTargetTitle = (
|
||||
labelIncludeAny?: ILabelSoftwareTitle[],
|
||||
labelExcludeAny?: ILabelSoftwareTitle[]
|
||||
) => {
|
||||
if (labelIncludeAny && labelIncludeAny.length > 0) {
|
||||
return "Targets (include any)";
|
||||
} else if (labelExcludeAny && labelExcludeAny.length > 0) {
|
||||
return "Targets (exclude any)";
|
||||
}
|
||||
return "Targets";
|
||||
};
|
||||
|
||||
const generateTargetValue = (
|
||||
labelIncludeAny?: ILabelSoftwareTitle[],
|
||||
labelExcludeAny?: ILabelSoftwareTitle[]
|
||||
) => {
|
||||
// handle single label case
|
||||
if (labelIncludeAny) {
|
||||
return <TargetValue labels={labelIncludeAny} />;
|
||||
} else if (labelExcludeAny) {
|
||||
return <TargetValue labels={labelExcludeAny} />;
|
||||
}
|
||||
return "None";
|
||||
};
|
||||
|
||||
interface ISoftwareDetailsModalProps {
|
||||
details: IActivityDetails;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SoftwareDetailsModal = ({
|
||||
details,
|
||||
onCancel,
|
||||
}: ISoftwareDetailsModalProps) => {
|
||||
const { labels_include_any, labels_exclude_any } = details;
|
||||
const hasTargets = labels_include_any || labels_exclude_any;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Software details"
|
||||
width="large"
|
||||
onExit={onCancel}
|
||||
onEnter={onCancel}
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
<DataSet title="Name" value={details.software_title} />
|
||||
<DataSet title="Package name" value={details.software_package} />
|
||||
<DataSet
|
||||
title="Self-Service"
|
||||
value={details.self_service ? "Yes" : "No"}
|
||||
/>
|
||||
{hasTargets && (
|
||||
<DataSet
|
||||
title={generateTargetTitle(
|
||||
labels_include_any,
|
||||
labels_exclude_any
|
||||
)}
|
||||
value={generateTargetValue(
|
||||
labels_include_any,
|
||||
labels_exclude_any
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwareDetailsModal;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
.software-details-modal {
|
||||
&__modal-content {
|
||||
display: flex;
|
||||
gap: $pad-xxlarge;
|
||||
}
|
||||
|
||||
.react-tooltip {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SoftwareDetailsModal";
|
||||
|
|
@ -11,16 +11,13 @@ import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
|||
import mdmAPI from "services/entities/mdm";
|
||||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Button from "components/buttons/Button";
|
||||
import Card from "components/Card";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import DataError from "components/DataError";
|
||||
import Icon from "components/Icon";
|
||||
import Modal from "components/Modal";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import Spinner from "components/Spinner";
|
||||
|
||||
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||
import ProfileGraphic from "../AddProfileGraphic";
|
||||
|
||||
import {
|
||||
|
|
@ -30,9 +27,7 @@ import {
|
|||
} from "../../helpers";
|
||||
import {
|
||||
CUSTOM_TARGET_OPTIONS,
|
||||
CustomTargetOption,
|
||||
generateLabelKey,
|
||||
getDescriptionText,
|
||||
listNamesFromSelectedLabels,
|
||||
} from "./helpers";
|
||||
|
||||
|
|
@ -91,118 +86,6 @@ const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
interface ITargetChooserProps {
|
||||
selectedTarget: string;
|
||||
setSelectedTarget: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const TargetChooser = ({
|
||||
selectedTarget,
|
||||
setSelectedTarget,
|
||||
}: ITargetChooserProps) => {
|
||||
return (
|
||||
<div className={`form-field`}>
|
||||
<div className="form-field__label">Target</div>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="All hosts"
|
||||
id="all-hosts-target-radio-btn"
|
||||
checked={selectedTarget === "All hosts"}
|
||||
value="All hosts"
|
||||
name="target-type"
|
||||
onChange={setSelectedTarget}
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Custom"
|
||||
id="custom-target-radio-btn"
|
||||
checked={selectedTarget === "Custom"}
|
||||
value="Custom"
|
||||
name="target-type"
|
||||
onChange={setSelectedTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILabelChooserProps {
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
labels: ILabelSummary[];
|
||||
selectedLabels: Record<string, boolean>;
|
||||
customTargetOption: CustomTargetOption;
|
||||
setSelectedLabels: React.Dispatch<
|
||||
React.SetStateAction<Record<string, boolean>>
|
||||
>;
|
||||
onSelectCustomTargetOption: (val: CustomTargetOption) => void;
|
||||
}
|
||||
|
||||
const LabelChooser = ({
|
||||
isError,
|
||||
isLoading,
|
||||
labels,
|
||||
selectedLabels,
|
||||
customTargetOption,
|
||||
setSelectedLabels,
|
||||
onSelectCustomTargetOption,
|
||||
}: ILabelChooserProps) => {
|
||||
const updateSelectedLabels = useCallback(
|
||||
({ name, value }: { name: string; value: boolean }) => {
|
||||
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
|
||||
},
|
||||
[setSelectedLabels]
|
||||
);
|
||||
|
||||
const renderLabels = () => {
|
||||
if (isLoading) {
|
||||
return <Spinner centered={false} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
if (!labels.length) {
|
||||
return (
|
||||
<div className={`${baseClass}__no-labels`}>
|
||||
<b>No labels exist in Fleet</b>
|
||||
<span>Add labels to target specific hosts.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return labels.map((label) => {
|
||||
return (
|
||||
<div className={`${baseClass}__label`} key={label.name}>
|
||||
<Checkbox
|
||||
className={`${baseClass}__checkbox`}
|
||||
name={label.name}
|
||||
value={!!selectedLabels[label.name]}
|
||||
onChange={updateSelectedLabels}
|
||||
parseTarget
|
||||
/>
|
||||
<div className={`${baseClass}__label-name`}>{label.name}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__custom-label-chooser`}>
|
||||
<Dropdown
|
||||
value={customTargetOption}
|
||||
options={CUSTOM_TARGET_OPTIONS}
|
||||
searchable={false}
|
||||
onChange={onSelectCustomTargetOption}
|
||||
/>
|
||||
<div className={`${baseClass}__description`}>
|
||||
{getDescriptionText(customTargetOption)}
|
||||
</div>
|
||||
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAddProfileModalProps {
|
||||
currentTeamId: number;
|
||||
isPremiumTier: boolean;
|
||||
|
|
@ -223,14 +106,13 @@ const AddProfileModal = ({
|
|||
name: string;
|
||||
platform: string;
|
||||
} | null>(null);
|
||||
const [selectedTarget, setSelectedTarget] = useState("All hosts"); // "All hosts" | "Custom"
|
||||
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
|
||||
const [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [
|
||||
customTargetOption,
|
||||
setCustomTargetOption,
|
||||
] = useState<CustomTargetOption>("labelsIncludeAll");
|
||||
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
|
||||
"labelsIncludeAll"
|
||||
);
|
||||
|
||||
const fileRef = useRef<File | null>(null);
|
||||
|
||||
|
|
@ -242,7 +124,6 @@ const AddProfileModal = ({
|
|||
} = useQuery<ILabelSummary[], Error>(
|
||||
["custom_labels"],
|
||||
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
||||
|
||||
{
|
||||
enabled: isPremiumTier,
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
@ -268,8 +149,8 @@ const AddProfileModal = ({
|
|||
setIsLoading(true);
|
||||
try {
|
||||
const labelKey = generateLabelKey(
|
||||
selectedTarget,
|
||||
customTargetOption,
|
||||
selectedTargetType,
|
||||
selectedCustomTarget,
|
||||
selectedLabels
|
||||
);
|
||||
await mdmAPI.uploadProfile({
|
||||
|
|
@ -308,8 +189,16 @@ const AddProfileModal = ({
|
|||
}
|
||||
};
|
||||
|
||||
const onSelectCustomTargetOption = (val: CustomTargetOption) => {
|
||||
setCustomTargetOption(val);
|
||||
const onSelectTargetType = (val: string) => {
|
||||
setSelectedTargetType(val);
|
||||
};
|
||||
|
||||
const onSelectCustomTargetOption = (val: string) => {
|
||||
setSelectedCustomTarget(val);
|
||||
};
|
||||
|
||||
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
|
||||
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -327,23 +216,19 @@ const AddProfileModal = ({
|
|||
)}
|
||||
</Card>
|
||||
{isPremiumTier && (
|
||||
<div className={`${baseClass}__target`}>
|
||||
<TargetChooser
|
||||
selectedTarget={selectedTarget}
|
||||
setSelectedTarget={setSelectedTarget}
|
||||
/>
|
||||
{selectedTarget === "Custom" && (
|
||||
<LabelChooser
|
||||
customTargetOption={customTargetOption}
|
||||
isError={isErrorLabels}
|
||||
isLoading={isFetchingLabels}
|
||||
labels={labels || []}
|
||||
selectedLabels={selectedLabels}
|
||||
setSelectedLabels={setSelectedLabels}
|
||||
onSelectCustomTargetOption={onSelectCustomTargetOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TargetLabelSelector
|
||||
selectedTargetType={selectedTargetType}
|
||||
selectedCustomTarget={selectedCustomTarget}
|
||||
selectedLabels={selectedLabels}
|
||||
customTargetOptions={CUSTOM_TARGET_OPTIONS}
|
||||
className={`${baseClass}__target`}
|
||||
onSelectTargetType={onSelectTargetType}
|
||||
onSelectCustomTarget={onSelectCustomTargetOption}
|
||||
onSelectLabel={onSelectLabel}
|
||||
isErrorLabels={isErrorLabels}
|
||||
isLoadingLabels={isFetchingLabels || isLoadingLabels}
|
||||
labels={labels || []}
|
||||
/>
|
||||
)}
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
<Button
|
||||
|
|
@ -352,7 +237,7 @@ const AddProfileModal = ({
|
|||
onClick={onFileUpload}
|
||||
isLoading={isLoading}
|
||||
disabled={
|
||||
(selectedTarget === "Custom" &&
|
||||
(selectedTargetType === "Custom" &&
|
||||
!listNamesFromSelectedLabels(selectedLabels).length) ||
|
||||
!fileDetails
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,9 @@ export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
|||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export type CustomTargetOption =
|
||||
| "labelsIncludeAll"
|
||||
| "labelsIncldeAny"
|
||||
| "labelsExcludeAny";
|
||||
|
||||
export const generateLabelKey = (
|
||||
target: string,
|
||||
customTargetOption: CustomTargetOption,
|
||||
customTargetOption: string,
|
||||
selectedLabels: Record<string, boolean>
|
||||
) => {
|
||||
if (target !== "Custom") {
|
||||
|
|
@ -65,8 +60,3 @@ export const generateLabelKey = (
|
|||
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDescriptionText = (value: string) => {
|
||||
return CUSTOM_TARGET_OPTIONS.find((option) => option.value === value)
|
||||
?.helpText;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,31 @@
|
|||
import React, { useContext, useEffect } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { useQuery } from "react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
|
||||
import {
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
LEARN_MORE_ABOUT_BASE_LINK,
|
||||
} from "utilities/constants";
|
||||
import { getFileDetails, IFileDetails } from "utilities/file/fileUtils";
|
||||
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
|
||||
import softwareAPI, {
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
MAX_FILE_SIZE_MB,
|
||||
} from "services/entities/software";
|
||||
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { AppContext } from "context/app";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
|
||||
import CustomLink from "components/CustomLink";
|
||||
import FileProgressModal from "components/FileProgressModal";
|
||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
|
||||
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||
|
|
@ -46,6 +54,19 @@ const SoftwareCustomPackage = ({
|
|||
null
|
||||
);
|
||||
|
||||
const {
|
||||
data: labels,
|
||||
isLoading: isLoadingLabels,
|
||||
isError: isErrorLabels,
|
||||
} = useQuery<ILabelSummary[], Error>(
|
||||
["custom_labels"],
|
||||
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
enabled: isPremiumTier,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -159,29 +180,42 @@ const SoftwareCustomPackage = ({
|
|||
setUploadDetails(null);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoadingLabels) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (isErrorLabels) {
|
||||
return <DataError className={`${baseClass}__data-error`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PackageForm
|
||||
labels={labels || []}
|
||||
showSchemaButton={!isSidePanelOpen}
|
||||
onClickShowSchema={() => setSidePanelOpen(true)}
|
||||
className={`${baseClass}__package-form`}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{uploadDetails && (
|
||||
<FileProgressModal
|
||||
fileDetails={uploadDetails}
|
||||
fileProgress={uploadProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isPremiumTier) {
|
||||
return (
|
||||
<PremiumFeatureMessage className={`${baseClass}__premium-message`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<PackageForm
|
||||
showSchemaButton={!isSidePanelOpen}
|
||||
onClickShowSchema={() => setSidePanelOpen(true)}
|
||||
className={`${baseClass}__package-form`}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{uploadDetails && (
|
||||
<FileProgressModal
|
||||
fileDetails={uploadDetails}
|
||||
fileProgress={uploadProgress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <div className={baseClass}>{renderContent()}</div>;
|
||||
};
|
||||
|
||||
export default SoftwareCustomPackage;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
margin-top: $pad-xxxlarge;
|
||||
}
|
||||
|
||||
&__data-error {
|
||||
margin-top: $pad-xxxlarge;
|
||||
}
|
||||
|
||||
// formatting the form buttons to be on the left. Tshis is done here because
|
||||
// this form can be used in other places where the buttons should be on
|
||||
// the right.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Button from "components/buttons/Button";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||
|
||||
import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields";
|
||||
|
||||
import { generateFormValidation } from "./helpers";
|
||||
import {
|
||||
CUSTOM_TARGET_OPTIONS,
|
||||
generateFormValidation,
|
||||
generateHelpText,
|
||||
} from "./helpers";
|
||||
|
||||
const baseClass = "fleet-app-details-form";
|
||||
|
||||
|
|
@ -19,14 +26,19 @@ export interface IFleetMaintainedAppFormData {
|
|||
postInstallScript?: string;
|
||||
uninstallScript?: string;
|
||||
installType: string;
|
||||
targetType: string;
|
||||
customTarget: string;
|
||||
labelTargets: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface IFormValidation {
|
||||
isValid: boolean;
|
||||
preInstallQuery?: { isValid: boolean; message?: string };
|
||||
customTarget?: { isValid: boolean };
|
||||
}
|
||||
|
||||
interface IFleetAppDetailsFormProps {
|
||||
labels: ILabelSummary[] | null;
|
||||
defaultInstallScript: string;
|
||||
defaultPostInstallScript: string;
|
||||
defaultUninstallScript: string;
|
||||
|
|
@ -37,6 +49,7 @@ interface IFleetAppDetailsFormProps {
|
|||
}
|
||||
|
||||
const FleetAppDetailsForm = ({
|
||||
labels,
|
||||
defaultInstallScript,
|
||||
defaultPostInstallScript,
|
||||
defaultUninstallScript,
|
||||
|
|
@ -54,6 +67,9 @@ const FleetAppDetailsForm = ({
|
|||
postInstallScript: defaultPostInstallScript,
|
||||
uninstallScript: defaultUninstallScript,
|
||||
installType: "manual",
|
||||
targetType: "All hosts",
|
||||
customTarget: "labelsIncludeAny",
|
||||
labelTargets: {},
|
||||
});
|
||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||
isValid: true,
|
||||
|
|
@ -95,6 +111,26 @@ const FleetAppDetailsForm = ({
|
|||
setFormData(newData);
|
||||
};
|
||||
|
||||
const onSelectTargetType = (value: string) => {
|
||||
const newData = { ...formData, targetType: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onSelectCustomTargetOption = (value: string) => {
|
||||
const newData = { ...formData, customTarget: value };
|
||||
setFormData(newData);
|
||||
};
|
||||
|
||||
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
|
||||
const newData = {
|
||||
...formData,
|
||||
labelTargets: { ...formData.labelTargets, [name]: value },
|
||||
};
|
||||
setFormData(newData);
|
||||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit(formData);
|
||||
|
|
@ -136,6 +172,21 @@ const FleetAppDetailsForm = ({
|
|||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<TargetLabelSelector
|
||||
selectedTargetType={formData.targetType}
|
||||
selectedCustomTarget={formData.customTarget}
|
||||
selectedLabels={formData.labelTargets}
|
||||
customTargetOptions={CUSTOM_TARGET_OPTIONS}
|
||||
className={`${baseClass}__target`}
|
||||
dropdownHelpText={
|
||||
formData.targetType === "Custom" &&
|
||||
generateHelpText(formData.installType, formData.customTarget)
|
||||
}
|
||||
onSelectTargetType={onSelectTargetType}
|
||||
onSelectCustomTarget={onSelectCustomTargetOption}
|
||||
onSelectLabel={onSelectLabel}
|
||||
labels={labels || []}
|
||||
/>
|
||||
<Checkbox
|
||||
value={formData.selfService}
|
||||
onChange={onToggleSelfServiceCheckbox}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
|
||||
|
|
@ -8,6 +12,7 @@ import {
|
|||
|
||||
type IMessageFunc = (formData: IFleetMaintainedAppFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
|
|
@ -16,7 +21,7 @@ interface IValidation {
|
|||
}
|
||||
|
||||
const FORM_VALIDATION_CONFIG: Record<
|
||||
"preInstallQuery",
|
||||
IFormValidationKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
preInstallQuery: {
|
||||
|
|
@ -33,6 +38,22 @@ const FORM_VALIDATION_CONFIG: Record<
|
|||
},
|
||||
],
|
||||
},
|
||||
customTarget: {
|
||||
validations: [
|
||||
{
|
||||
name: "requiredLabelTargets",
|
||||
isValid: (formData) => {
|
||||
if (formData.targetType === "All hosts") return true;
|
||||
// there must be at least one label target selected
|
||||
return (
|
||||
Object.keys(formData.labelTargets).find(
|
||||
(key) => formData.labelTargets[key]
|
||||
) !== undefined
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const getErrorMessage = (
|
||||
|
|
@ -54,7 +75,7 @@ export const generateFormValidation = (
|
|||
};
|
||||
|
||||
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
|
||||
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
|
||||
const objKey = key as IFormValidationKey;
|
||||
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
|
||||
(validation) => !validation.isValid(formData)
|
||||
);
|
||||
|
|
@ -74,3 +95,45 @@ export const generateFormValidation = (
|
|||
|
||||
return formValidation;
|
||||
};
|
||||
|
||||
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
|
||||
{
|
||||
value: "labelsIncludeAny",
|
||||
label: "Include any",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: "labelsExcludeAny",
|
||||
label: "Exclude any",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const generateHelpText = (installType: string, customTarget: string) => {
|
||||
if (customTarget === "labelsIncludeAny") {
|
||||
return installType === "manual" ? (
|
||||
<>
|
||||
Software will only be available for install on hosts that{" "}
|
||||
<b>have any</b> of these labels:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Software will only be installed on hosts that <b>have any</b> of these
|
||||
labels:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// this is the case for labelsExcludeAny
|
||||
return installType === "manual" ? (
|
||||
<>
|
||||
Software will only be available for install on hosts that{" "}
|
||||
<b>don't have any</b> of these labels:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Software will only be installed on hosts that <b>don't have any</b>{" "}
|
||||
of these labels:{" "}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,11 +8,13 @@ import { buildQueryStringFromParams } from "utilities/url";
|
|||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import teamPoliciesAPI from "services/entities/team_policies";
|
||||
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
||||
import { QueryContext } from "context/query";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
import useToggleSidePanel from "hooks/useToggleSidePanel";
|
||||
|
||||
import BackLink from "components/BackLink";
|
||||
|
|
@ -113,7 +115,11 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
setShowAddFleetAppSoftwareModal,
|
||||
] = useState(false);
|
||||
|
||||
const { data: fleetApp, isLoading, isError } = useQuery(
|
||||
const {
|
||||
data: fleetApp,
|
||||
isLoading: isLoadingFleetApp,
|
||||
isError: isErrorFleetApp,
|
||||
} = useQuery(
|
||||
["fleet-maintained-app", appId],
|
||||
() => softwareAPI.getFleetMainainedApp(appId),
|
||||
{
|
||||
|
|
@ -123,6 +129,21 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: labels,
|
||||
isLoading: isLoadingLabels,
|
||||
isError: isErrorLabels,
|
||||
} = useQuery<ILabelSummary[], Error>(
|
||||
["custom_labels"],
|
||||
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
||||
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
enabled: isPremiumTier,
|
||||
staleTime: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const onOsqueryTableSelect = (tableName: string) => {
|
||||
setSelectedOsqueryTable(tableName);
|
||||
};
|
||||
|
|
@ -221,12 +242,12 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
return <PremiumFeatureMessage />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoadingFleetApp || isLoadingLabels) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <DataError />;
|
||||
if (isErrorFleetApp || isErrorLabels) {
|
||||
return <DataError className={`${baseClass}__data-error`} />;
|
||||
}
|
||||
|
||||
if (fleetApp) {
|
||||
|
|
@ -245,6 +266,7 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
version={fleetApp.version}
|
||||
/>
|
||||
<FleetAppDetailsForm
|
||||
labels={labels || []}
|
||||
showSchemaButton={!isSidePanelOpen}
|
||||
defaultInstallScript={fleetApp.install_script}
|
||||
defaultPostInstallScript={fleetApp.post_install_script}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
.fleet-maintained-app-details-page {
|
||||
&__data-error {
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__back-to-add-software {
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
import React, { useContext, useState, useEffect } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { useQuery } from "react-query";
|
||||
import classnames from "classnames";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
import { ISoftwarePackage } from "interfaces/software";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import softwareAPI, {
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
MAX_FILE_SIZE_MB,
|
||||
} from "services/entities/software";
|
||||
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
||||
|
||||
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
|
||||
import {
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
LEARN_MORE_ABOUT_BASE_LINK,
|
||||
} from "utilities/constants";
|
||||
import deepDifference from "utilities/deep_difference";
|
||||
import { getFileDetails } from "utilities/file/fileUtils";
|
||||
|
||||
|
|
@ -21,6 +27,12 @@ import Modal from "components/Modal";
|
|||
|
||||
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||
import {
|
||||
generateSelectedLabels,
|
||||
getCustomTarget,
|
||||
getTargetType,
|
||||
} from "pages/SoftwarePage/components/PackageForm/helpers";
|
||||
|
||||
import { getErrorMessage } from "./helpers";
|
||||
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
|
||||
|
||||
|
|
@ -29,8 +41,7 @@ const baseClass = "edit-software-modal";
|
|||
interface IEditSoftwareModalProps {
|
||||
softwareId: number;
|
||||
teamId: number;
|
||||
router: InjectedRouter;
|
||||
software?: any; // TODO
|
||||
software: ISoftwarePackage; // TODO
|
||||
refetchSoftwareTitle: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
|
@ -56,9 +67,24 @@ const EditSoftwareModal = ({
|
|||
software: null,
|
||||
installScript: "",
|
||||
selfService: false,
|
||||
targetType: "",
|
||||
customTarget: "",
|
||||
labelTargets: {},
|
||||
});
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const {
|
||||
data: labels,
|
||||
isLoading: isLoadingLabels,
|
||||
isError: isErrorLabels,
|
||||
} = useQuery<ILabelSummary[], Error>(
|
||||
["custom_labels"],
|
||||
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
}
|
||||
);
|
||||
|
||||
// Work around to not lose Edit Software modal data when Save changes modal opens
|
||||
// by using CSS to hide Edit Software modal when Save changes modal is open
|
||||
useEffect(() => {
|
||||
|
|
@ -118,6 +144,7 @@ const EditSoftwareModal = ({
|
|||
try {
|
||||
await softwareAPI.editSoftwarePackage({
|
||||
data: formData,
|
||||
orignalPackage: software,
|
||||
softwareId,
|
||||
teamId,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
|
|
@ -147,7 +174,7 @@ const EditSoftwareModal = ({
|
|||
if (isTimeout) {
|
||||
renderFlash(
|
||||
"error",
|
||||
`Couldn’t upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
|
||||
`Couldn't upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
|
||||
);
|
||||
} else if (reason.includes("Fleet couldn't read the version from")) {
|
||||
renderFlash(
|
||||
|
|
@ -184,18 +211,21 @@ const EditSoftwareModal = ({
|
|||
postInstallScript: software.post_install_script || "",
|
||||
uninstallScript: software.uninstall_script || "",
|
||||
selfService: software.self_service || false,
|
||||
targetType: getTargetType(software),
|
||||
customTarget: getCustomTarget(software),
|
||||
labelTargets: generateSelectedLabels(software),
|
||||
});
|
||||
|
||||
setPendingUpdates(formData);
|
||||
|
||||
const onlySelfServiceUpdated =
|
||||
Object.keys(updates).length === 1 && "selfService" in updates;
|
||||
if (!onlySelfServiceUpdated) {
|
||||
// Open the confirm save changes modal
|
||||
setShowConfirmSaveChangesModal(true);
|
||||
} else {
|
||||
if (onlySelfServiceUpdated) {
|
||||
// Proceed with saving changes (API expects only changes)
|
||||
onSaveSoftwareChanges(formData);
|
||||
} else {
|
||||
// Open the confirm save changes modal
|
||||
setShowConfirmSaveChangesModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -213,6 +243,7 @@ const EditSoftwareModal = ({
|
|||
width="large"
|
||||
>
|
||||
<PackageForm
|
||||
labels={labels ?? []}
|
||||
className={`${baseClass}__package-form`}
|
||||
isEditingSoftware
|
||||
onCancel={onExit}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import React, {
|
|||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { AppContext } from "context/app";
|
||||
|
|
@ -239,7 +238,6 @@ interface ISoftwarePackageCardProps {
|
|||
// NOTE: we will only have this if we are working with a software package.
|
||||
softwarePackage?: ISoftwarePackage;
|
||||
onDelete: () => void;
|
||||
router: InjectedRouter;
|
||||
refetchSoftwareTitle: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +254,6 @@ const SoftwarePackageCard = ({
|
|||
softwareId,
|
||||
teamId,
|
||||
onDelete,
|
||||
router,
|
||||
refetchSoftwareTitle,
|
||||
}: ISoftwarePackageCardProps) => {
|
||||
const {
|
||||
|
|
@ -393,13 +390,12 @@ const SoftwarePackageCard = ({
|
|||
teamId={teamId}
|
||||
/>
|
||||
</div>
|
||||
{showEditSoftwareModal && (
|
||||
{showEditSoftwareModal && softwarePackage && (
|
||||
<EditSoftwareModal
|
||||
softwareId={softwareId}
|
||||
teamId={teamId}
|
||||
software={softwarePackage}
|
||||
onExit={() => setShowEditSoftwareModal(false)}
|
||||
router={router}
|
||||
refetchSoftwareTitle={refetchSoftwareTitle}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,6 @@ const SoftwareTitleDetailsPage = ({
|
|||
softwareId={softwareId}
|
||||
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
|
||||
onDelete={onDeleteInstaller}
|
||||
router={router}
|
||||
refetchSoftwareTitle={refetchSoftwareTitle}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ describe("SoftwareTitleDetailsPage helpers", () => {
|
|||
name: "Test Software",
|
||||
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
|
||||
software_package: {
|
||||
labels_include_any: null,
|
||||
labels_exclude_any: null,
|
||||
name: "TestPackage.pkg",
|
||||
version: "1.0.0",
|
||||
self_service: true,
|
||||
|
|
@ -21,6 +23,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
|
|||
failed_uninstall: 1,
|
||||
},
|
||||
install_script: "echo foo",
|
||||
uninstall_script: "echo bar",
|
||||
icon_url: "https://example.com/icon.png",
|
||||
automatic_install_policies: [],
|
||||
last_install: null,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,23 @@ import { NotificationContext } from "context/notification";
|
|||
import { getFileDetails } from "utilities/file/fileUtils";
|
||||
import getDefaultInstallScript from "utilities/software_install_scripts";
|
||||
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
|
||||
import { ILabelSummary } from "interfaces/label";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||
|
||||
import PackageAdvancedOptions from "../PackageAdvancedOptions";
|
||||
|
||||
import { generateFormValidation } from "./helpers";
|
||||
import {
|
||||
CUSTOM_TARGET_OPTIONS,
|
||||
generateFormValidation,
|
||||
generateSelectedLabels,
|
||||
getCustomTarget,
|
||||
getTargetType,
|
||||
} from "./helpers";
|
||||
|
||||
export const baseClass = "package-form";
|
||||
|
||||
|
|
@ -25,18 +33,20 @@ export interface IPackageFormData {
|
|||
postInstallScript?: string;
|
||||
uninstallScript?: string;
|
||||
selfService: boolean;
|
||||
targetType: string;
|
||||
customTarget: string;
|
||||
labelTargets: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface IFormValidation {
|
||||
isValid: boolean;
|
||||
software: { isValid: boolean };
|
||||
preInstallQuery?: { isValid: boolean; message?: string };
|
||||
postInstallScript?: { isValid: boolean; message?: string };
|
||||
uninstallScript?: { isValid: boolean; message?: string };
|
||||
selfService?: { isValid: boolean };
|
||||
customTarget?: { isValid: boolean };
|
||||
}
|
||||
|
||||
interface IPackageFormProps {
|
||||
labels: ILabelSummary[];
|
||||
showSchemaButton?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (formData: IPackageFormData) => void;
|
||||
|
|
@ -54,6 +64,7 @@ interface IPackageFormProps {
|
|||
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
|
||||
|
||||
const PackageForm = ({
|
||||
labels,
|
||||
showSchemaButton = false,
|
||||
onClickShowSchema,
|
||||
onCancel,
|
||||
|
|
@ -69,15 +80,17 @@ const PackageForm = ({
|
|||
}: IPackageFormProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const initialFormData = {
|
||||
const [formData, setFormData] = useState<IPackageFormData>({
|
||||
software: defaultSoftware || null,
|
||||
installScript: defaultInstallScript || "",
|
||||
preInstallQuery: defaultPreInstallQuery || "",
|
||||
postInstallScript: defaultPostInstallScript || "",
|
||||
uninstallScript: defaultUninstallScript || "",
|
||||
selfService: defaultSelfService || false,
|
||||
};
|
||||
const [formData, setFormData] = useState<IPackageFormData>(initialFormData);
|
||||
targetType: getTargetType(defaultSoftware),
|
||||
customTarget: getCustomTarget(defaultSoftware),
|
||||
labelTargets: generateSelectedLabels(defaultSoftware),
|
||||
});
|
||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||
isValid: false,
|
||||
software: { isValid: false },
|
||||
|
|
@ -156,6 +169,27 @@ const PackageForm = ({
|
|||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onSelectTargetType = (value: string) => {
|
||||
const newData = { ...formData, targetType: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onSelectCustomTarget = (value: string) => {
|
||||
const newData = { ...formData, customTarget: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
|
||||
const newData = {
|
||||
...formData,
|
||||
labelTargets: { ...formData.labelTargets, [name]: value },
|
||||
};
|
||||
setFormData(newData);
|
||||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const isSubmitDisabled = !formValidation.isValid;
|
||||
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
|
@ -176,6 +210,17 @@ const PackageForm = ({
|
|||
formData.software ? getFileDetails(formData.software) : undefined
|
||||
}
|
||||
/>
|
||||
<TargetLabelSelector
|
||||
selectedTargetType={formData.targetType}
|
||||
selectedCustomTarget={formData.customTarget}
|
||||
selectedLabels={formData.labelTargets}
|
||||
customTargetOptions={CUSTOM_TARGET_OPTIONS}
|
||||
className={`${baseClass}__target`}
|
||||
onSelectTargetType={onSelectTargetType}
|
||||
onSelectCustomTarget={onSelectCustomTarget}
|
||||
onSelectLabel={onSelectLabel}
|
||||
labels={labels || []}
|
||||
/>
|
||||
<Checkbox
|
||||
value={formData.selfService}
|
||||
onChange={onToggleSelfServiceCheckbox}
|
||||
|
|
@ -196,7 +241,6 @@ const PackageForm = ({
|
|||
selectedPackage={formData.software}
|
||||
errors={{
|
||||
preInstallQuery: formValidation.preInstallQuery?.message,
|
||||
postInstallScript: formValidation.postInstallScript?.message,
|
||||
}}
|
||||
preInstallQuery={formData.preInstallQuery}
|
||||
installScript={formData.installScript}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import { ISoftwarePackage } from "interfaces/software";
|
||||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
|
||||
import { IPackageFormData, IFormValidation } from "./PackageForm";
|
||||
|
||||
type IPackageFormValidatorKey = Exclude<
|
||||
keyof IPackageFormData,
|
||||
"installScript" | "uninstallScript"
|
||||
>;
|
||||
|
||||
type IMessageFunc = (formData: IPackageFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
|
|
@ -17,11 +16,11 @@ interface IValidation {
|
|||
message?: IValidationMessage;
|
||||
}
|
||||
|
||||
/** configuration defines validations for each filed in the form. It defines rules
|
||||
/** configuration defines validations for each field in the form. It defines rules
|
||||
* to determine if a field is valid, and rules for generating an error message.
|
||||
*/
|
||||
const FORM_VALIDATION_CONFIG: Record<
|
||||
IPackageFormValidatorKey,
|
||||
IFormValidationKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
software: {
|
||||
|
|
@ -46,13 +45,21 @@ const FORM_VALIDATION_CONFIG: Record<
|
|||
},
|
||||
],
|
||||
},
|
||||
postInstallScript: {
|
||||
// no validations related to postInstallScript
|
||||
validations: [],
|
||||
},
|
||||
selfService: {
|
||||
// no validations related to self service
|
||||
validations: [],
|
||||
customTarget: {
|
||||
validations: [
|
||||
{
|
||||
name: "requiredLabelTargets",
|
||||
isValid: (formData) => {
|
||||
if (formData.targetType === "All hosts") return true;
|
||||
// there must be at least one label target selected
|
||||
return (
|
||||
Object.keys(formData.labelTargets).find(
|
||||
(key) => formData.labelTargets[key]
|
||||
) !== undefined
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -97,3 +104,56 @@ export const generateFormValidation = (formData: IPackageFormData) => {
|
|||
};
|
||||
|
||||
export default generateFormValidation;
|
||||
|
||||
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
|
||||
{
|
||||
value: "labelsIncludeAny",
|
||||
label: "Include any",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: "labelsExcludeAny",
|
||||
label: "Exclude any",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const getTargetType = (softwarePackage: ISoftwarePackage) => {
|
||||
if (!softwarePackage) return "All hosts";
|
||||
|
||||
return !softwarePackage.labels_include_any &&
|
||||
!softwarePackage.labels_exclude_any
|
||||
? "All hosts"
|
||||
: "Custom";
|
||||
};
|
||||
|
||||
export const getCustomTarget = (softwarePackage: ISoftwarePackage) => {
|
||||
if (!softwarePackage) return "labelsIncludeAny";
|
||||
|
||||
return softwarePackage.labels_include_any
|
||||
? "labelsIncludeAny"
|
||||
: "labelsExcludeAny";
|
||||
};
|
||||
|
||||
export const generateSelectedLabels = (softwarePackage: ISoftwarePackage) => {
|
||||
if (
|
||||
!softwarePackage ||
|
||||
(!softwarePackage.labels_include_any && !softwarePackage.labels_exclude_any)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const customTypeKey = softwarePackage.labels_include_any
|
||||
? "labels_include_any"
|
||||
: "labels_exclude_any";
|
||||
|
||||
return (
|
||||
softwarePackage[customTypeKey]?.reduce<Record<string, boolean>>(
|
||||
(acc, label) => {
|
||||
acc[label.name] = true;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
) ?? {}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ import {
|
|||
MANAGE_HOSTS_PAGE_FILTER_KEYS,
|
||||
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS,
|
||||
} from "./HostsPageConfig";
|
||||
import { isAcceptableStatus } from "./helpers";
|
||||
import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers";
|
||||
|
||||
import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal";
|
||||
import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal";
|
||||
|
|
@ -1061,13 +1061,7 @@ const ManageHostsPage = ({
|
|||
);
|
||||
renderFlash("success", "Successfully deleted label.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderFlash(
|
||||
"error",
|
||||
getErrorReason(error).includes("built-in")
|
||||
? "Built-in labels can’t be modified or deleted."
|
||||
: "Could not delete label. Please try again."
|
||||
);
|
||||
renderFlash("error", getDeleteLabelErrorMessages(error));
|
||||
} finally {
|
||||
setIsUpdatingLabel(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ const DeleteLabelModal = ({
|
|||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
<p>Are you sure you wish to delete this label?</p>
|
||||
<p>
|
||||
If a configuration profile uses this label as a custom target, the
|
||||
profile will break: it won't be applied to new hosts.
|
||||
</p>
|
||||
<p>
|
||||
To apply the profile to new hosts, you'll have to delete it and
|
||||
upload a new profile.
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { getErrorReason } from "interfaces/errors";
|
||||
|
||||
export const isAcceptableStatus = (filter: string): boolean => {
|
||||
return (
|
||||
filter === "new" ||
|
||||
|
|
@ -21,3 +23,25 @@ export const isValidPemCertificate = (cert: string): boolean => {
|
|||
|
||||
return regexPemHeader.test(cert) && regexPemFooter.test(cert);
|
||||
};
|
||||
|
||||
const hasStatusKey = (value: unknown): value is { status: number } => {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"status" in value &&
|
||||
typeof (value as any).status === "number"
|
||||
);
|
||||
};
|
||||
|
||||
export const getDeleteLabelErrorMessages = (error: unknown): string => {
|
||||
// unprocessable content status. Label is used in a custom profile
|
||||
// or software target. we have to check that status exists on the error object
|
||||
// before we can access it.
|
||||
if (hasStatusKey(error) && error.status === 422) {
|
||||
return getErrorReason(error).includes("built-in")
|
||||
? "Built-in labels can't be modified or deleted."
|
||||
: "Couldn't delete. Software uses this label as a custom target. Please delete the software and try again.";
|
||||
}
|
||||
|
||||
return "Could not delete label. Please try again.";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ISoftwareTitleDetails,
|
||||
IFleetMaintainedApp,
|
||||
IFleetMaintainedAppDetails,
|
||||
ISoftwarePackage,
|
||||
} from "interfaces/software";
|
||||
import {
|
||||
buildQueryStringFromParams,
|
||||
|
|
@ -17,6 +18,8 @@ import {
|
|||
} from "utilities/url";
|
||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
|
||||
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
|
||||
import { join } from "path";
|
||||
|
||||
export interface ISoftwareApiParams {
|
||||
page?: number;
|
||||
|
|
@ -138,6 +141,8 @@ interface IAddFleetMaintainedAppPostBody {
|
|||
post_install_script?: string;
|
||||
uninstall_script?: string;
|
||||
self_service?: boolean;
|
||||
labels_include_any?: string[];
|
||||
labels_exclude_any?: string[];
|
||||
}
|
||||
|
||||
const ORDER_KEY = "name";
|
||||
|
|
@ -276,6 +281,19 @@ export default {
|
|||
formData.append("post_install_script", data.postInstallScript);
|
||||
teamId && formData.append("team_id", teamId.toString());
|
||||
|
||||
if (data.targetType === "Custom") {
|
||||
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
|
||||
let labelKey = "";
|
||||
if (data.customTarget === "labelsIncludeAny") {
|
||||
labelKey = "labels_include_any";
|
||||
} else {
|
||||
labelKey = "labels_exclude_any";
|
||||
}
|
||||
selectedLabels?.forEach((label) => {
|
||||
formData.append(labelKey, label);
|
||||
});
|
||||
}
|
||||
|
||||
return sendRequestWithProgress({
|
||||
method: "POST",
|
||||
path: SOFTWARE_PACKAGE_ADD,
|
||||
|
|
@ -289,6 +307,7 @@ export default {
|
|||
|
||||
editSoftwarePackage: ({
|
||||
data,
|
||||
orignalPackage,
|
||||
softwareId,
|
||||
teamId,
|
||||
timeout,
|
||||
|
|
@ -296,6 +315,7 @@ export default {
|
|||
signal,
|
||||
}: {
|
||||
data: IPackageFormData;
|
||||
orignalPackage: ISoftwarePackage;
|
||||
softwareId: number;
|
||||
teamId: number;
|
||||
timeout?: number;
|
||||
|
|
@ -313,6 +333,29 @@ export default {
|
|||
formData.append("post_install_script", data.postInstallScript || "");
|
||||
formData.append("uninstall_script", data.uninstallScript || "");
|
||||
|
||||
// clear out labels if targetType is "All hosts"
|
||||
if (data.targetType === "All hosts") {
|
||||
if (orignalPackage.labels_include_any) {
|
||||
formData.append("labels_include_any", "");
|
||||
} else {
|
||||
formData.append("labels_exclude_any", "");
|
||||
}
|
||||
}
|
||||
|
||||
// add custom labels if targetType is "Custom"
|
||||
if (data.targetType === "Custom") {
|
||||
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
|
||||
let labelKey = "";
|
||||
if (data.customTarget === "labelsIncludeAny") {
|
||||
labelKey = "labels_include_any";
|
||||
} else {
|
||||
labelKey = "labels_exclude_any";
|
||||
}
|
||||
selectedLabels?.forEach((label) => {
|
||||
formData.append(labelKey, label);
|
||||
});
|
||||
}
|
||||
|
||||
return sendRequestWithProgress({
|
||||
method: "PATCH",
|
||||
path: EDIT_SOFTWARE_PACKAGE(softwareId),
|
||||
|
|
@ -380,6 +423,15 @@ export default {
|
|||
self_service: formData.selfService,
|
||||
};
|
||||
|
||||
if (formData.targetType === "Custom") {
|
||||
const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets);
|
||||
if (formData.customTarget === "labelsIncludeAny") {
|
||||
body.labels_include_any = selectedLabels;
|
||||
} else {
|
||||
body.labels_exclude_any = selectedLabels;
|
||||
}
|
||||
}
|
||||
|
||||
return sendRequest("POST", SOFTWARE_FLEET_MAINTAINED_APPS, body);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -879,6 +879,10 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
|
|||
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
|
||||
continue
|
||||
}
|
||||
if len(softwarePackageSpec.LabelsExcludeAny) > 0 && len(softwarePackageSpec.LabelsIncludeAny) > 0 {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for software URL %q`, softwarePackageSpec.URL))
|
||||
continue
|
||||
}
|
||||
result.Software.Packages = append(result.Software.Packages, &softwarePackageSpec)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -397,40 +397,43 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
|
|||
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install foo",
|
||||
InstallerFile: installer1,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "foo.pkg",
|
||||
Title: "foo",
|
||||
Source: "apps",
|
||||
Version: "0.0.1",
|
||||
UserID: u.ID,
|
||||
InstallScript: "install foo",
|
||||
InstallerFile: installer1,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "foo.pkg",
|
||||
Title: "foo",
|
||||
Source: "apps",
|
||||
Version: "0.0.1",
|
||||
UserID: u.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install bar",
|
||||
InstallerFile: installer2,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "bar.pkg",
|
||||
Title: "bar",
|
||||
Source: "apps",
|
||||
Version: "0.0.2",
|
||||
UserID: u.ID,
|
||||
InstallScript: "install bar",
|
||||
InstallerFile: installer2,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "bar.pkg",
|
||||
Title: "bar",
|
||||
Source: "apps",
|
||||
Version: "0.0.2",
|
||||
UserID: u.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install to delete",
|
||||
InstallerFile: installer3,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "todelete.pkg",
|
||||
Title: "todelete",
|
||||
Source: "apps",
|
||||
Version: "0.0.3",
|
||||
UserID: u.ID,
|
||||
InstallScript: "install to delete",
|
||||
InstallerFile: installer3,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "todelete.pkg",
|
||||
Title: "todelete",
|
||||
Source: "apps",
|
||||
Version: "0.0.3",
|
||||
UserID: u.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
||||
|
|
|
|||
|
|
@ -6940,6 +6940,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
|||
PreInstallQuery: "",
|
||||
Title: "ChocolateRain",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil)
|
||||
|
|
|
|||
|
|
@ -252,6 +252,9 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string) error {
|
|||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, labelID)
|
||||
if err != nil {
|
||||
if isMySQLForeignKey(err) {
|
||||
return ctxerr.Wrap(ctx, foreignKey("labels", name), "delete label")
|
||||
}
|
||||
return ctxerr.Wrapf(ctx, err, "delete label")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -918,25 +919,65 @@ func testLabelsRecordNonexistentQueryLabelExecution(t *testing.T, db *Datastore)
|
|||
}
|
||||
|
||||
func testDeleteLabel(t *testing.T, db *Datastore) {
|
||||
l, err := db.NewLabel(context.Background(), &fleet.Label{
|
||||
ctx := context.Background()
|
||||
l, err := db.NewLabel(ctx, &fleet.Label{
|
||||
Name: t.Name(),
|
||||
Query: "query1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
p, err := db.NewPack(context.Background(), &fleet.Pack{
|
||||
p, err := db.NewPack(ctx, &fleet.Pack{
|
||||
Name: t.Name(),
|
||||
LabelIDs: []uint{l.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.DeleteLabel(context.Background(), l.Name))
|
||||
require.NoError(t, db.DeleteLabel(ctx, l.Name))
|
||||
|
||||
newP, err := db.Pack(context.Background(), p.ID)
|
||||
newP, err := db.Pack(ctx, p.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, newP.Labels)
|
||||
|
||||
require.NoError(t, db.DeletePack(context.Background(), newP.Name))
|
||||
require.NoError(t, db.DeletePack(ctx, newP.Name))
|
||||
|
||||
// delete a non-existing label
|
||||
err = db.DeleteLabel(ctx, "no-such-label")
|
||||
require.Error(t, err)
|
||||
var nfe fleet.NotFoundError
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
|
||||
// create a software installer and scope it via a label
|
||||
u := test.NewUser(t, db, "user1", "user1@example.com", false)
|
||||
installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
installerID, _, err := db.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install foo",
|
||||
InstallerFile: installer,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "foo.pkg",
|
||||
Title: "foo",
|
||||
Source: "apps",
|
||||
Version: "0.0.1",
|
||||
UserID: u.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
l2, err := db.NewLabel(ctx, &fleet.Label{
|
||||
Name: t.Name() + "2",
|
||||
Query: "query2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, installerID, l2.ID)
|
||||
return err
|
||||
})
|
||||
|
||||
// try to delete that label referenced by software installer
|
||||
err = db.DeleteLabel(ctx, l2.Name)
|
||||
require.Error(t, err)
|
||||
require.True(t, fleet.IsForeignKey(err))
|
||||
}
|
||||
|
||||
func testLabelsSummary(t *testing.T, db *Datastore) {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
|||
UserID: user.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
BundleIdentifier: "irrelevant_1",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -233,6 +234,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
|||
UserID: user.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
BundleIdentifier: "fleet.maintained1",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -252,6 +254,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
|||
UserID: user.ID,
|
||||
Platform: string(fleet.IOSPlatform),
|
||||
BundleIdentifier: "fleet.maintained1",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20241220114904, Down_20241220114904)
|
||||
}
|
||||
|
||||
func Up_20241220114904(tx *sql.Tx) error {
|
||||
createVppAppStmt := `
|
||||
CREATE TABLE IF NOT EXISTS vpp_app_team_labels (
|
||||
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
vpp_app_team_id INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- unlike for configuration profiles, the referenced label for software
|
||||
-- cannot be deleted, so we make it NOT NULL and no need to capture the name.
|
||||
label_id INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- if exclude is true, "exclude_any" condition, otherwise "include_any"
|
||||
-- (we don't support include/exclude all for now, so not adding a
|
||||
-- "require_all" column).
|
||||
exclude TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
|
||||
UNIQUE KEY idx_vpp_app_team_labels_vpp_app_team_id_label_id (vpp_app_team_id, label_id),
|
||||
|
||||
FOREIGN KEY (vpp_app_team_id) REFERENCES vpp_apps_teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- because we want to prevent deleting a label if it is referenced by a vpp app,
|
||||
-- we explicitly enforce this at the database level with the RESTRICT clause.
|
||||
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`
|
||||
if _, err := tx.Exec(createVppAppStmt); err != nil {
|
||||
return errors.Wrap(err, "create vpp_app_team_labels table")
|
||||
}
|
||||
|
||||
createSoftwareInstallerStmt := `
|
||||
CREATE TABLE IF NOT EXISTS software_installer_labels (
|
||||
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
software_installer_id INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- unlike for configuration profiles, the referenced label for software
|
||||
-- cannot be deleted, so we make it NOT NULL and no need to capture the name.
|
||||
label_id INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- if exclude is true, "exclude_any" condition, otherwise "include_any"
|
||||
-- (we don't support include/exclude all for now, so not adding a
|
||||
-- "require_all" column).
|
||||
exclude TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
|
||||
UNIQUE KEY idx_software_installer_labels_software_installer_id_label_id (software_installer_id, label_id),
|
||||
|
||||
FOREIGN KEY (software_installer_id) REFERENCES software_installers(id) ON DELETE CASCADE,
|
||||
|
||||
-- because we want to prevent deleting a label if it is referenced by an installer,
|
||||
-- we explicitly enforce this at the database level with the RESTRICT clause.
|
||||
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`
|
||||
if _, err := tx.Exec(createSoftwareInstallerStmt); err != nil {
|
||||
return errors.Wrap(err, "create software_installer_labels table")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20241220114904(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -287,6 +287,7 @@ func testGlobalPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) {
|
|||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
|
||||
|
|
@ -885,6 +886,7 @@ func testTeamPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user.ID,
|
||||
TeamID: &team2.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
|
||||
|
|
@ -1444,6 +1446,7 @@ func testPoliciesByID(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy2.SoftwareInstallerID = ptr.Uint(installerID)
|
||||
|
|
@ -4192,6 +4195,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, p1.SoftwareInstallerID)
|
||||
|
|
@ -4230,6 +4234,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: ptr.Uint(fleet.PolicyNoTeamID),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{
|
||||
|
|
@ -4451,6 +4456,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
|
||||
|
|
@ -4470,6 +4476,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
|||
Source: "deb_packages",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
|
||||
|
|
@ -4489,6 +4496,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
|||
Source: "rpm_packages",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
|
||||
|
|
@ -4509,6 +4517,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
|||
Source: "programs",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID)
|
||||
|
|
@ -4700,6 +4709,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
|
||||
|
|
@ -5229,6 +5239,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
|
||||
|
|
@ -5248,6 +5259,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
|
||||
|
|
@ -5304,6 +5316,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -5319,6 +5332,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1298,6 +1298,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
|
|||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1358,6 +1359,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
|
|||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -330,6 +332,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -349,6 +352,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -368,6 +372,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
Platform: string(fleet.IOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -464,6 +469,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
_ = installerID1
|
||||
require.NoError(t, err)
|
||||
|
|
@ -483,6 +489,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
_ = installerID2
|
||||
require.NoError(t, err)
|
||||
|
|
@ -503,6 +510,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
_ = installerID3
|
||||
require.NoError(t, err)
|
||||
|
|
@ -523,6 +531,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
Platform: string(fleet.IOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
_ = installerID4
|
||||
require.NoError(t, err)
|
||||
|
|
@ -678,7 +687,7 @@ func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) {
|
|||
// We need a new user first
|
||||
user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")})
|
||||
require.NoError(t, err)
|
||||
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID})
|
||||
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID, ValidatedLabels: &fleet.LabelIdentsWithScope{}})
|
||||
require.NoError(t, err)
|
||||
installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -2296,6 +2296,8 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
|||
host_vpp_software_installs hvsi ON vat.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id AND hvsi.removed = 0
|
||||
LEFT OUTER JOIN
|
||||
nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
|
||||
LEFT OUTER JOIN
|
||||
host_script_results hsr ON hsr.host_id = :host_id AND hsr.execution_id = hsi.last_uninstall_execution_id
|
||||
WHERE
|
||||
-- use the latest VPP install attempt only
|
||||
( hvsi.id IS NULL OR hvsi.id = (
|
||||
|
|
@ -2309,6 +2311,67 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
|||
-- on host (via installer or VPP app). If only available for install is
|
||||
-- requested, then the software installed on host clause is empty.
|
||||
( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL )
|
||||
AND
|
||||
-- label membership check
|
||||
(
|
||||
-- do the label membership check only for software installers
|
||||
CASE WHEN (si.ID IS NOT NULL AND hsi.last_uninstalled_at IS NOT NULL AND hsr.exit_code = 0) THEN
|
||||
(
|
||||
EXISTS (
|
||||
|
||||
SELECT 1 FROM (
|
||||
|
||||
-- no labels
|
||||
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
-- include any
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
0 as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
|
||||
AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = si.id
|
||||
AND sil.exclude = 0
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_host_labels > 0
|
||||
|
||||
UNION
|
||||
|
||||
-- exclude any, ignore software that depends on labels created
|
||||
-- _after_ the label_updated_at timestamp of the host (because
|
||||
-- we don't have results for that label yet, the host may or may
|
||||
-- not be a member).
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN labels lbl
|
||||
ON lbl.id = sil.label_id
|
||||
LEFT OUTER JOIN label_membership lm
|
||||
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = si.id
|
||||
AND sil.exclude = 1
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
|
||||
) t
|
||||
)
|
||||
)
|
||||
-- it's some other type of software that has been checked above
|
||||
ELSE true END
|
||||
)
|
||||
|
||||
%s
|
||||
`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause)
|
||||
|
||||
|
|
@ -2375,7 +2438,66 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
|||
hvsi.removed = 0
|
||||
) AND
|
||||
-- either the software installer or the vpp app exists for the host's team
|
||||
( si.id IS NOT NULL OR vat.platform = :host_platform )
|
||||
( si.id IS NOT NULL OR vat.platform = :host_platform ) AND
|
||||
-- label membership check
|
||||
(
|
||||
-- do the label membership check only for software installers
|
||||
CASE WHEN si.ID IS NOT NULL THEN
|
||||
(
|
||||
EXISTS (
|
||||
|
||||
SELECT 1 FROM (
|
||||
|
||||
-- no labels
|
||||
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
-- include any
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
0 as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
|
||||
AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = si.id
|
||||
AND sil.exclude = 0
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_host_labels > 0
|
||||
|
||||
UNION
|
||||
|
||||
-- exclude any, ignore software that depends on labels created
|
||||
-- _after_ the label_updated_at timestamp of the host (because
|
||||
-- we don't have results for that label yet, the host may or may
|
||||
-- not be a member).
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN labels lbl
|
||||
ON lbl.id = sil.label_id
|
||||
LEFT OUTER JOIN label_membership lm
|
||||
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = si.id
|
||||
AND sil.exclude = 1
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
|
||||
) t
|
||||
)
|
||||
)
|
||||
-- it's some other type of software that has been checked above
|
||||
ELSE true END
|
||||
)
|
||||
%s %s
|
||||
`, onlySelfServiceClause, excludeVPPAppsClause)
|
||||
|
||||
|
|
@ -2415,6 +2537,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
|||
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
|
||||
"global_or_team_id": globalOrTeamID,
|
||||
"is_mdm_enrolled": opts.IsMDMEnrolled,
|
||||
"host_label_updated_at": host.LabelUpdatedAt,
|
||||
}
|
||||
|
||||
stmt := stmtInstalled
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/kit/log/level"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -92,6 +93,12 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId
|
|||
}
|
||||
|
||||
func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (installerID, titleID uint, err error) {
|
||||
if payload.ValidatedLabels == nil {
|
||||
// caller must ensure this is not nil; if caller intends no labels to be created,
|
||||
// payload.ValidatedLabels should point to an empty struct.
|
||||
return 0, 0, errors.New("validated labels must not be nil")
|
||||
}
|
||||
|
||||
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload)
|
||||
if err != nil {
|
||||
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
|
||||
|
|
@ -130,7 +137,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload
|
|||
}
|
||||
}
|
||||
|
||||
stmt := `
|
||||
if err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
stmt := `
|
||||
INSERT INTO software_installers (
|
||||
team_id,
|
||||
global_or_team_id,
|
||||
|
|
@ -152,39 +160,49 @@ INSERT INTO software_installers (
|
|||
fleet_library_app_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?)`
|
||||
|
||||
args := []interface{}{
|
||||
tid,
|
||||
globalOrTeamID,
|
||||
titleID,
|
||||
payload.StorageID,
|
||||
payload.Filename,
|
||||
payload.Extension,
|
||||
payload.Version,
|
||||
strings.Join(payload.PackageIDs, ","),
|
||||
installScriptID,
|
||||
payload.PreInstallQuery,
|
||||
postInstallScriptID,
|
||||
uninstallScriptID,
|
||||
payload.Platform,
|
||||
payload.SelfService,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.FleetLibraryAppID,
|
||||
}
|
||||
|
||||
res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
if IsDuplicate(err) {
|
||||
// already exists for this team/no team
|
||||
err = alreadyExists("SoftwareInstaller", payload.Title)
|
||||
args := []interface{}{
|
||||
tid,
|
||||
globalOrTeamID,
|
||||
titleID,
|
||||
payload.StorageID,
|
||||
payload.Filename,
|
||||
payload.Extension,
|
||||
payload.Version,
|
||||
strings.Join(payload.PackageIDs, ","),
|
||||
installScriptID,
|
||||
payload.PreInstallQuery,
|
||||
postInstallScriptID,
|
||||
uninstallScriptID,
|
||||
payload.Platform,
|
||||
payload.SelfService,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.FleetLibraryAppID,
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
if IsDuplicate(err) {
|
||||
// already exists for this team/no team
|
||||
err = alreadyExists("SoftwareInstaller", payload.Title)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
id, _ := res.LastInsertId()
|
||||
installerID = uint(id) //nolint:gosec // dismiss G115
|
||||
|
||||
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, 0, ctxerr.Wrap(ctx, err, "insert software installer")
|
||||
}
|
||||
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
return uint(id), titleID, nil //nolint:gosec // dismiss G115
|
||||
return installerID, titleID, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
|
||||
|
|
@ -237,6 +255,61 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit
|
|||
return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles")
|
||||
}
|
||||
|
||||
// setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software
|
||||
// installer. If no labels are provided, it will remove all label associations with the software installer.
|
||||
func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope) error {
|
||||
labelIds := make([]uint, 0, len(labels.ByName))
|
||||
for _, label := range labels.ByName {
|
||||
labelIds = append(labelIds, label.LabelID)
|
||||
}
|
||||
|
||||
// remove existing labels
|
||||
delArgs := []interface{}{installerID}
|
||||
delStmt := `DELETE FROM software_installer_labels WHERE software_installer_id = ?`
|
||||
if len(labelIds) > 0 {
|
||||
inStmt, args, err := sqlx.In(` AND label_id NOT IN (?)`, labelIds)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "build delete existing software installer labels query")
|
||||
}
|
||||
delArgs = append(delArgs, args...)
|
||||
delStmt += inStmt
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, delStmt, delArgs...)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete existing software installer labels")
|
||||
}
|
||||
|
||||
// insert new labels
|
||||
if len(labelIds) > 0 {
|
||||
var exclude bool
|
||||
switch labels.LabelScope {
|
||||
case fleet.LabelScopeIncludeAny:
|
||||
exclude = false
|
||||
case fleet.LabelScopeExcludeAny:
|
||||
exclude = true
|
||||
default:
|
||||
// this should never happen
|
||||
return ctxerr.New(ctx, "invalid label scope")
|
||||
}
|
||||
|
||||
stmt := `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`
|
||||
var placeholders string
|
||||
var insertArgs []interface{}
|
||||
for _, lid := range labelIds {
|
||||
placeholders += "(?, ?, ?),"
|
||||
insertArgs = append(insertArgs, installerID, lid, exclude)
|
||||
}
|
||||
placeholders = strings.TrimSuffix(placeholders, ",")
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, placeholders), insertArgs...)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert software installer label")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error {
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id)
|
||||
if err != nil {
|
||||
|
|
@ -275,7 +348,8 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
|
|||
touchUploaded = ", uploaded_at = NOW()"
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf(`UPDATE software_installers SET
|
||||
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
stmt := fmt.Sprintf(`UPDATE software_installers SET
|
||||
storage_id = ?,
|
||||
filename = ?,
|
||||
version = ?,
|
||||
|
|
@ -290,23 +364,34 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
|
|||
user_email = (SELECT email FROM users WHERE id = ?) %s
|
||||
WHERE id = ?`, touchUploaded)
|
||||
|
||||
args := []interface{}{
|
||||
payload.StorageID,
|
||||
payload.Filename,
|
||||
payload.Version,
|
||||
strings.Join(payload.PackageIDs, ","),
|
||||
installScriptID,
|
||||
*payload.PreInstallQuery,
|
||||
postInstallScriptID,
|
||||
uninstallScriptID,
|
||||
*payload.SelfService,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.InstallerID,
|
||||
}
|
||||
args := []interface{}{
|
||||
payload.StorageID,
|
||||
payload.Filename,
|
||||
payload.Version,
|
||||
strings.Join(payload.PackageIDs, ","),
|
||||
installScriptID,
|
||||
*payload.PreInstallQuery,
|
||||
postInstallScriptID,
|
||||
uninstallScriptID,
|
||||
*payload.SelfService,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.UserID,
|
||||
payload.InstallerID,
|
||||
}
|
||||
|
||||
_, err = ds.writer(ctx).ExecContext(ctx, stmt, args...)
|
||||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update software installer")
|
||||
}
|
||||
|
||||
if payload.ValidatedLabels != nil {
|
||||
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update software installer")
|
||||
}
|
||||
|
|
@ -423,6 +508,28 @@ WHERE
|
|||
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
|
||||
}
|
||||
|
||||
// TODO: do we want to include labels on other queries that return software installer metadata
|
||||
// (e.g., GetSoftwareInstallerMetadataByID)?
|
||||
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
|
||||
}
|
||||
var exclAny, inclAny []fleet.SoftwareScopeLabel
|
||||
for _, l := range labels {
|
||||
if l.Exclude {
|
||||
exclAny = append(exclAny, l)
|
||||
} else {
|
||||
inclAny = append(inclAny, l)
|
||||
}
|
||||
}
|
||||
|
||||
if len(inclAny) > 0 && len(exclAny) > 0 {
|
||||
// there's a bug somewhere
|
||||
level.Debug(ds.logger).Log("msg", "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny))
|
||||
}
|
||||
dest.LabelsExcludeAny = exclAny
|
||||
dest.LabelsIncludeAny = inclAny
|
||||
|
||||
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
|
||||
|
|
@ -432,6 +539,28 @@ WHERE
|
|||
return &dest, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) {
|
||||
query := `
|
||||
SELECT
|
||||
label_id,
|
||||
exclude,
|
||||
l.name as label_name,
|
||||
si.title_id
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
JOIN software_installers si ON si.id = sil.software_installer_id
|
||||
JOIN labels l ON l.id = sil.label_id
|
||||
WHERE
|
||||
software_installer_id = ?`
|
||||
|
||||
var labels []fleet.SoftwareScopeLabel
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
|
||||
errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again."}
|
||||
|
|
@ -1040,7 +1169,56 @@ ON DUPLICATE KEY UPDATE
|
|||
user_name = VALUES(user_name),
|
||||
user_email = VALUES(user_email),
|
||||
url = VALUES(url),
|
||||
install_during_setup = COALESCE(?, install_during_setup)
|
||||
install_during_setup = COALESCE(?, install_during_setup)
|
||||
`
|
||||
|
||||
const loadSoftwareInstallerID = `
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
software_installers
|
||||
WHERE
|
||||
global_or_team_id = ? AND
|
||||
-- this is guaranteed to select a single title_id, due to unique index
|
||||
title_id IN (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '')
|
||||
`
|
||||
|
||||
const deleteInstallerLabelsNotInList = `
|
||||
DELETE FROM
|
||||
software_installer_labels
|
||||
WHERE
|
||||
software_installer_id = ? AND
|
||||
label_id NOT IN (?)
|
||||
`
|
||||
|
||||
const deleteAllInstallerLabels = `
|
||||
DELETE FROM
|
||||
software_installer_labels
|
||||
WHERE
|
||||
software_installer_id = ?
|
||||
`
|
||||
|
||||
const upsertInstallerLabels = `
|
||||
INSERT INTO
|
||||
software_installer_labels (
|
||||
software_installer_id,
|
||||
label_id,
|
||||
exclude
|
||||
)
|
||||
VALUES
|
||||
%s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
exclude = VALUES(exclude)
|
||||
`
|
||||
|
||||
const loadExistingInstallerLabels = `
|
||||
SELECT
|
||||
label_id,
|
||||
exclude
|
||||
FROM
|
||||
software_installer_labels
|
||||
WHERE
|
||||
software_installer_id = ?
|
||||
`
|
||||
|
||||
// use a team id of 0 if no-team
|
||||
|
|
@ -1058,7 +1236,7 @@ ON DUPLICATE KEY UPDATE
|
|||
replacingInstallDuringSetup = true
|
||||
}
|
||||
|
||||
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
// if no installers are provided, just delete whatever was in
|
||||
// the table
|
||||
if len(installers) == 0 {
|
||||
|
|
@ -1159,6 +1337,10 @@ ON DUPLICATE KEY UPDATE
|
|||
}
|
||||
|
||||
for _, installer := range installers {
|
||||
if installer.ValidatedLabels == nil {
|
||||
return ctxerr.Errorf(ctx, "labels have not been validated for installer with name %s", installer.Filename)
|
||||
}
|
||||
|
||||
isRes, err := insertScriptContents(ctx, tx, installer.InstallScript)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "inserting install script contents for software installer with name %q", installer.Filename)
|
||||
|
|
@ -1244,7 +1426,84 @@ ON DUPLICATE KEY UPDATE
|
|||
return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename)
|
||||
}
|
||||
|
||||
// perform side effects if this was an update
|
||||
// now that the software installer is created/updated, load its installer
|
||||
// ID (cannot use res.LastInsertID due to the upsert statement, won't
|
||||
// give the id in case of update)
|
||||
var installerID uint
|
||||
if err := sqlx.GetContext(ctx, tx, &installerID, loadSoftwareInstallerID, globalOrTeamID, installer.Title, installer.Source); err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "load id of new/edited installer with name %q", installer.Filename)
|
||||
}
|
||||
|
||||
// process the labels associated with that software installer
|
||||
if len(installer.ValidatedLabels.ByName) == 0 {
|
||||
// no label to apply, so just delete all existing labels if any
|
||||
res, err := tx.ExecContext(ctx, deleteAllInstallerLabels, installerID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "delete installer labels for %s", installer.Filename)
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
|
||||
// if it did delete a row, then the target changed so pending
|
||||
// installs/uninstalls must be deleted
|
||||
existing[0].IsMetadataModified = true
|
||||
}
|
||||
} else {
|
||||
// there are new labels to apply, delete only the obsolete ones
|
||||
labelIDs := make([]uint, 0, len(installer.ValidatedLabels.ByName))
|
||||
for _, lbl := range installer.ValidatedLabels.ByName {
|
||||
labelIDs = append(labelIDs, lbl.LabelID)
|
||||
}
|
||||
stmt, args, err := sqlx.In(deleteInstallerLabelsNotInList, installerID, labelIDs)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "build statement to delete installer labels not in list")
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "delete installer labels not in list for %s", installer.Filename)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
|
||||
// if it did delete a row, then the target changed so pending
|
||||
// installs/uninstalls must be deleted
|
||||
existing[0].IsMetadataModified = true
|
||||
}
|
||||
|
||||
excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny
|
||||
if len(existing) > 0 && !existing[0].IsMetadataModified {
|
||||
// load the remaining labels for that installer, so that we can detect
|
||||
// if any label changed (if the counts differ, then labels did change,
|
||||
// otherwise if the exclude bool changed, the target did change).
|
||||
var existingLabels []struct {
|
||||
LabelID uint `db:"label_id"`
|
||||
Exclude bool `db:"exclude"`
|
||||
}
|
||||
if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInstallerLabels, installerID); err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "load existing labels for installer with name %q", installer.Filename)
|
||||
}
|
||||
|
||||
if len(existingLabels) != len(labelIDs) {
|
||||
existing[0].IsMetadataModified = true
|
||||
}
|
||||
if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels {
|
||||
// same labels are provided, but the include <-> exclude changed
|
||||
existing[0].IsMetadataModified = true
|
||||
}
|
||||
}
|
||||
|
||||
// upsert the new labels now that obsolete ones have been deleted
|
||||
var upsertLabelArgs []any
|
||||
for _, lblID := range labelIDs {
|
||||
upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels)
|
||||
}
|
||||
upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",")
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInstallerLabels, upsertLabelValues), upsertLabelArgs...)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "insert new/edited labels for installer with name %q", installer.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
// perform side effects if this was an update (related to pending (un)install requests)
|
||||
if len(existing) > 0 {
|
||||
if err := ds.runInstallerUpdateSideEffectsInTransaction(
|
||||
ctx,
|
||||
|
|
@ -1259,10 +1518,7 @@ ON DUPLICATE KEY UPDATE
|
|||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) {
|
||||
|
|
@ -1367,3 +1623,79 @@ WHERE global_or_team_id = ?
|
|||
}
|
||||
return softwarePackages, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) {
|
||||
stmt := `
|
||||
SELECT 1 FROM (
|
||||
|
||||
-- no labels
|
||||
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = :installer_id
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
-- include any
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
0 as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
|
||||
AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = :installer_id
|
||||
AND sil.exclude = 0
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_host_labels > 0
|
||||
|
||||
UNION
|
||||
|
||||
-- exclude any, ignore software that depends on labels created
|
||||
-- _after_ the label_updated_at timestamp of the host (because
|
||||
-- we don't have results for that label yet, the host may or may
|
||||
-- not be a member).
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
SUM(CASE
|
||||
WHEN
|
||||
lbl.created_at IS NOT NULL AND (SELECT label_updated_at FROM hosts WHERE id = :host_id) >= lbl.created_at THEN 1
|
||||
ELSE
|
||||
0
|
||||
END) as count_host_updated_after_labels
|
||||
FROM
|
||||
software_installer_labels sil
|
||||
LEFT OUTER JOIN labels lbl
|
||||
ON lbl.id = sil.label_id
|
||||
LEFT OUTER JOIN label_membership lm
|
||||
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
|
||||
WHERE
|
||||
sil.software_installer_id = :installer_id
|
||||
AND sil.exclude = 1
|
||||
HAVING
|
||||
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
|
||||
) t
|
||||
`
|
||||
namedArgs := map[string]any{
|
||||
"host_id": hostID,
|
||||
"installer_id": installerID,
|
||||
}
|
||||
stmt, args, err := sqlx.Named(stmt, namedArgs)
|
||||
if err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "build named query for is software installer label scoped")
|
||||
}
|
||||
|
||||
var res bool
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, ctxerr.Wrap(ctx, err, "is software installer label scoped")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package mysql
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -37,6 +38,7 @@ func TestSoftwareInstallers(t *testing.T) {
|
|||
{"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy},
|
||||
{"GetHostLastInstallData", testGetHostLastInstallData},
|
||||
{"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID},
|
||||
{"BatchSetSoftwareInstallersScopedViaLabels", testBatchSetSoftwareInstallersScopedViaLabels},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -84,6 +86,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
|||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -100,6 +103,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
|||
Version: "2.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -117,6 +121,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
|||
Source: "apps",
|
||||
SelfService: true,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -219,12 +224,13 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
|
|||
require.Nil(t, si)
|
||||
|
||||
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "foo",
|
||||
Source: "bar",
|
||||
InstallScript: "echo",
|
||||
TeamID: teamID,
|
||||
Filename: "foo.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "foo",
|
||||
Source: "bar",
|
||||
InstallScript: "echo",
|
||||
TeamID: teamID,
|
||||
Filename: "foo.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||
|
|
@ -519,13 +525,14 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
|
|||
// create a host and software installer
|
||||
swFilename := "file_" + tc.name + ".pkg"
|
||||
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "foo" + tc.name,
|
||||
Source: "bar" + tc.name,
|
||||
InstallScript: "echo " + tc.name,
|
||||
Version: "1.11",
|
||||
TeamID: &teamID,
|
||||
Filename: swFilename,
|
||||
UserID: user1.ID,
|
||||
Title: "foo" + tc.name,
|
||||
Source: "bar" + tc.name,
|
||||
InstallScript: "echo " + tc.name,
|
||||
Version: "1.11",
|
||||
TeamID: &teamID,
|
||||
Filename: swFilename,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
|
|
@ -650,13 +657,14 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
assertExisting([]string{ins0})
|
||||
|
||||
swi, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer0",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer0",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -739,6 +747,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID)
|
||||
|
|
@ -772,6 +781,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
Platform: "darwin",
|
||||
URL: "https://example.com",
|
||||
InstallDuringSetup: ptr.Bool(true),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
{
|
||||
InstallScript: "install",
|
||||
|
|
@ -786,6 +796,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example2.com",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -818,6 +829,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
Version: "2",
|
||||
PreInstallQuery: "select 1 from bar;",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
|
@ -839,6 +851,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
Platform: "darwin",
|
||||
URL: "https://example.com",
|
||||
InstallDuringSetup: nil,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
{
|
||||
InstallScript: "install",
|
||||
|
|
@ -853,6 +866,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example2.com",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -872,6 +886,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
Platform: "darwin",
|
||||
URL: "https://example.com",
|
||||
InstallDuringSetup: ptr.Bool(false),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
{
|
||||
InstallScript: "install",
|
||||
|
|
@ -886,6 +901,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example2.com",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -903,6 +919,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
Version: "2",
|
||||
PreInstallQuery: "select 1 from bar;",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -941,6 +958,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
|
|||
Filename: "foo.pkg",
|
||||
Platform: "darwin",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||
|
|
@ -955,12 +973,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
|
|||
require.Equal(t, "SELECT 1", metaByTeamAndTitle.PreInstallQuery)
|
||||
|
||||
installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "bar",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "bar",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||
|
|
@ -994,14 +1013,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
|
||||
// Create a non-self service installer
|
||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "foo",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo.pkg",
|
||||
Platform: platform,
|
||||
SelfService: false,
|
||||
UserID: user1.ID,
|
||||
Title: "foo",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo.pkg",
|
||||
Platform: platform,
|
||||
SelfService: false,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
|
||||
|
|
@ -1013,14 +1033,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
|
||||
// Create a self-service installer for team
|
||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "foo2",
|
||||
Source: "bar2",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo2.pkg",
|
||||
Platform: platform,
|
||||
SelfService: true,
|
||||
UserID: user1.ID,
|
||||
Title: "foo2",
|
||||
Source: "bar2",
|
||||
InstallScript: "echo install",
|
||||
TeamID: &team.ID,
|
||||
Filename: "foo2.pkg",
|
||||
Platform: platform,
|
||||
SelfService: true,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
|
||||
|
|
@ -1052,14 +1073,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
|
||||
// Create a global self-service installer
|
||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "foo global",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: nil,
|
||||
Filename: "foo global.pkg",
|
||||
Platform: platform,
|
||||
SelfService: true,
|
||||
UserID: user1.ID,
|
||||
Title: "foo global",
|
||||
Source: "bar",
|
||||
InstallScript: "echo install",
|
||||
TeamID: nil,
|
||||
Filename: "foo global.pkg",
|
||||
Platform: platform,
|
||||
SelfService: true,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil)
|
||||
|
|
@ -1103,15 +1125,16 @@ func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
|
||||
softwareInstallerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1177,15 +1200,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
|
||||
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1197,15 +1221,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
|
||||
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins1",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins1",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1292,27 +1317,29 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
|
||||
softwareInstallerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins1",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer.pkg",
|
||||
Title: "ins1",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
softwareInstallerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install2",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer2.pkg",
|
||||
Title: "ins2",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
InstallScript: "install2",
|
||||
InstallerFile: tfr0,
|
||||
StorageID: ins0,
|
||||
Filename: "installer2.pkg",
|
||||
Title: "ins2",
|
||||
Source: "apps",
|
||||
Platform: "darwin",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1492,3 +1519,260 @@ func testGetOrGenerateSoftwareInstallerTitleID(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testBatchSetSoftwareInstallersScopedViaLabels(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
// create a host to have a pending install request
|
||||
host := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now())
|
||||
|
||||
// create a couple teams and a user
|
||||
tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "1"})
|
||||
require.NoError(t, err)
|
||||
tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"})
|
||||
require.NoError(t, err)
|
||||
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
|
||||
// create some installer payloads to be used by test cases
|
||||
installers := make([]*fleet.UploadSoftwareInstallerPayload, 3)
|
||||
for i := range installers {
|
||||
file := bytes.NewReader([]byte("installer" + fmt.Sprint(i)))
|
||||
tfr, err := fleet.NewTempFileReader(file, t.TempDir)
|
||||
require.NoError(t, err)
|
||||
installers[i] = &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
InstallerFile: tfr,
|
||||
StorageID: "installer" + fmt.Sprint(i),
|
||||
Filename: "installer" + fmt.Sprint(i),
|
||||
Title: "ins" + fmt.Sprint(i),
|
||||
Source: "apps",
|
||||
Version: "1",
|
||||
PreInstallQuery: "foo",
|
||||
UserID: user.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com",
|
||||
}
|
||||
}
|
||||
|
||||
// create some labels to be used by test cases
|
||||
labels := make([]*fleet.Label, 4)
|
||||
for i := range labels {
|
||||
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "label" + fmt.Sprint(i)})
|
||||
require.NoError(t, err)
|
||||
labels[i] = lbl
|
||||
}
|
||||
|
||||
type testPayload struct {
|
||||
Installer *fleet.UploadSoftwareInstallerPayload
|
||||
Labels []*fleet.Label
|
||||
Exclude bool
|
||||
ShouldCancelPending *bool // nil if the installer is new (could not have pending), otherwise true/false if it was edited
|
||||
}
|
||||
|
||||
// test scenarios - note that subtests must NOT be used as the sequence of
|
||||
// tests matters - they cannot be run in isolation.
|
||||
cases := []struct {
|
||||
desc string
|
||||
team *fleet.Team
|
||||
payload []testPayload
|
||||
}{
|
||||
{
|
||||
desc: "empty payload",
|
||||
payload: nil,
|
||||
},
|
||||
{
|
||||
desc: "no team, installer0, no label",
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0]},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0, include label0",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[0]}},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no team, installer0 no change, add installer1 with exclude label1",
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], ShouldCancelPending: ptr.Bool(false)},
|
||||
{Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no team, installer0 no change, installer1 change to include label1",
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], ShouldCancelPending: ptr.Bool(false)},
|
||||
{Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: false, ShouldCancelPending: ptr.Bool(true)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0, include label0 and add label1",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[1]}, ShouldCancelPending: ptr.Bool(true)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0, remove label0 and keep label1",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[1]}, ShouldCancelPending: ptr.Bool(true)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0, switch to label0 and label2",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(true)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 2, 3 installers, mix of labels",
|
||||
team: tm2,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[0]}, Exclude: false},
|
||||
{Installer: installers[1], Labels: []*fleet.Label{labels[0], labels[1], labels[2]}, Exclude: true},
|
||||
{Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0 no change and add installer2",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(false)},
|
||||
{Installer: installers[2]},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 1, installer0 switch to labels 1 and 3, installer2 no change",
|
||||
team: tm1,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[0], Labels: []*fleet.Label{labels[1], labels[3]}, ShouldCancelPending: ptr.Bool(true)},
|
||||
{Installer: installers[2], ShouldCancelPending: ptr.Bool(false)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "team 2, remove installer0, labels of install1 and no change installer2",
|
||||
team: tm2,
|
||||
payload: []testPayload{
|
||||
{Installer: installers[1], ShouldCancelPending: ptr.Bool(true)},
|
||||
{Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false, ShouldCancelPending: ptr.Bool(false)},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no team, remove all",
|
||||
payload: []testPayload{},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Log("Running test case ", c.desc)
|
||||
|
||||
var teamID *uint
|
||||
var globalOrTeamID uint
|
||||
if c.team != nil {
|
||||
teamID = &c.team.ID
|
||||
globalOrTeamID = c.team.ID
|
||||
}
|
||||
|
||||
// cleanup any existing install requests for the host
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM host_software_installs WHERE host_id = ?`, host.ID)
|
||||
return err
|
||||
})
|
||||
|
||||
installerIDs := make([]uint, len(c.payload))
|
||||
if len(c.payload) > 0 {
|
||||
// create pending install requests for each updated installer, to see if
|
||||
// it cancels it or not as expected.
|
||||
err := ds.AddHostsToTeam(ctx, teamID, []uint{host.ID})
|
||||
require.NoError(t, err)
|
||||
for i, payload := range c.payload {
|
||||
if payload.ShouldCancelPending != nil {
|
||||
// the installer must exist
|
||||
var swID uint
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
err := sqlx.GetContext(ctx, q, &swID, `SELECT id FROM software_installers WHERE global_or_team_id = ?
|
||||
AND title_id IN (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '')`,
|
||||
globalOrTeamID, payload.Installer.Title, payload.Installer.Source)
|
||||
return err
|
||||
})
|
||||
_, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swID, false, nil)
|
||||
require.NoError(t, err)
|
||||
installerIDs[i] = swID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create the payload by copying the test one, so that the original installers
|
||||
// structs are not modified
|
||||
payload := make([]*fleet.UploadSoftwareInstallerPayload, len(c.payload))
|
||||
for i, p := range c.payload {
|
||||
installer := *p.Installer
|
||||
installer.ValidatedLabels = &fleet.LabelIdentsWithScope{LabelScope: fleet.LabelScopeIncludeAny}
|
||||
if p.Exclude {
|
||||
installer.ValidatedLabels.LabelScope = fleet.LabelScopeExcludeAny
|
||||
}
|
||||
byName := make(map[string]fleet.LabelIdent, len(p.Labels))
|
||||
for _, lbl := range p.Labels {
|
||||
byName[lbl.Name] = fleet.LabelIdent{LabelName: lbl.Name, LabelID: lbl.ID}
|
||||
}
|
||||
installer.ValidatedLabels.ByName = byName
|
||||
payload[i] = &installer
|
||||
}
|
||||
|
||||
err := ds.BatchSetSoftwareInstallers(ctx, teamID, payload)
|
||||
require.NoError(t, err)
|
||||
installers, err := ds.GetSoftwareInstallers(ctx, globalOrTeamID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, installers, len(c.payload))
|
||||
|
||||
// get the metadata for each installer to assert the batch did set the
|
||||
// expected ones.
|
||||
installersByFilename := make(map[string]*fleet.SoftwareInstaller, len(installers))
|
||||
for _, ins := range installers {
|
||||
meta, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, *ins.TitleID, false)
|
||||
require.NoError(t, err)
|
||||
installersByFilename[meta.Name] = meta
|
||||
}
|
||||
|
||||
// validate that the inserted software is as expected
|
||||
for i, payload := range c.payload {
|
||||
meta, ok := installersByFilename[payload.Installer.Filename]
|
||||
require.True(t, ok, "installer %s was not created", payload.Installer.Filename)
|
||||
require.Equal(t, meta.SoftwareTitle, payload.Installer.Title)
|
||||
|
||||
wantLabelIDs := make([]uint, len(payload.Labels))
|
||||
for j, lbl := range payload.Labels {
|
||||
wantLabelIDs[j] = lbl.ID
|
||||
}
|
||||
if payload.Exclude {
|
||||
require.Empty(t, meta.LabelsIncludeAny)
|
||||
gotLabelIDs := make([]uint, len(meta.LabelsExcludeAny))
|
||||
for i, lbl := range meta.LabelsExcludeAny {
|
||||
gotLabelIDs[i] = lbl.LabelID
|
||||
}
|
||||
require.ElementsMatch(t, wantLabelIDs, gotLabelIDs)
|
||||
} else {
|
||||
require.Empty(t, meta.LabelsExcludeAny)
|
||||
gotLabelIDs := make([]uint, len(meta.LabelsIncludeAny))
|
||||
for j, lbl := range meta.LabelsIncludeAny {
|
||||
gotLabelIDs[j] = lbl.LabelID
|
||||
}
|
||||
require.ElementsMatch(t, wantLabelIDs, gotLabelIDs)
|
||||
}
|
||||
|
||||
// check if it deleted pending installs or not
|
||||
if payload.ShouldCancelPending != nil {
|
||||
lastInstall, err := ds.GetHostLastInstallData(ctx, host.ID, installerIDs[i])
|
||||
require.NoError(t, err)
|
||||
if *payload.ShouldCancelPending {
|
||||
require.Nil(t, lastInstall, "should have cancelled pending installs")
|
||||
} else {
|
||||
require.NotNil(t, lastInstall, "should not have cancelled pending installs")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func TestSoftware(t *testing.T) {
|
|||
{"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam},
|
||||
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
|
||||
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
|
||||
{"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
@ -4615,15 +4616,16 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) {
|
|||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user.ID,
|
||||
InstallScript: "hello",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -4727,15 +4729,16 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore
|
|||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user.ID,
|
||||
InstallScript: "hello",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -5246,3 +5249,283 @@ func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
// create a host
|
||||
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
|
||||
nanoEnroll(t, ds, host, false)
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
|
||||
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
|
||||
|
||||
// create a software installer
|
||||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
installer1 := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
UninstallScript: "goodbye",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
BundleIdentifier: "bi1",
|
||||
Platform: "darwin",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
}
|
||||
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// we should see installer1, since it has no label associated yet
|
||||
opts := fleet.HostSoftwareTitleListOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
PerPage: 11,
|
||||
IncludeMetadata: true,
|
||||
OrderKey: "name",
|
||||
TestSecondaryOrderKey: "source",
|
||||
},
|
||||
IncludeAvailableForInstall: true,
|
||||
}
|
||||
expectedInstallers := map[string]*fleet.SoftwarePackageOrApp{
|
||||
installer1.Filename: {
|
||||
Name: installer1.Filename,
|
||||
Version: installer1.Version,
|
||||
SelfService: ptr.Bool(false),
|
||||
},
|
||||
}
|
||||
|
||||
checkSoftware := func(swList []*fleet.HostSoftwareWithInstaller, excludeNames ...string) {
|
||||
for _, got := range swList {
|
||||
want, ok := expectedInstallers[got.SoftwarePackage.Name]
|
||||
if slices.Contains(excludeNames, got.SoftwarePackage.Name) {
|
||||
require.False(t, ok)
|
||||
continue
|
||||
}
|
||||
require.True(t, ok)
|
||||
require.Equal(t, want, got.SoftwarePackage)
|
||||
}
|
||||
}
|
||||
|
||||
software, _, err := ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software)
|
||||
|
||||
// installer1 should be in scope since it has no labels
|
||||
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
|
||||
require.NoError(t, err)
|
||||
|
||||
// assign the label to the host
|
||||
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))
|
||||
host.LabelUpdatedAt = time.Now()
|
||||
err = ds.UpdateHost(ctx, host)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// assign the label to the software installer
|
||||
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
|
||||
LabelScope: fleet.LabelScopeExcludeAny,
|
||||
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// should be empty as the installer label is "exclude any"
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, software)
|
||||
|
||||
// installer1 should be out of scope since the label is "exclude any"
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, scoped)
|
||||
|
||||
// Update the label to be "include any"
|
||||
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
|
||||
LabelScope: fleet.LabelScopeIncludeAny,
|
||||
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software)
|
||||
|
||||
// Now installer1 is in scope again: label is "include any"
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// Add an installer. No label yet.
|
||||
installer2 := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
UninstallScript: "goodbye",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage2",
|
||||
Filename: "file2",
|
||||
Title: "file2",
|
||||
Version: "2.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
BundleIdentifier: "bi2",
|
||||
Platform: "darwin",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
}
|
||||
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer2)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedInstallers[installer2.Filename] = &fleet.SoftwarePackageOrApp{
|
||||
Name: installer2.Filename,
|
||||
Version: installer2.Version,
|
||||
SelfService: ptr.Bool(false),
|
||||
}
|
||||
|
||||
// There's 2 installers now: installerID1 and installerID2 (because it has no labels associated)
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software)
|
||||
|
||||
// Add "exclude any" labels to installer2
|
||||
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()})
|
||||
require.NoError(t, err)
|
||||
|
||||
label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID2, fleet.LabelIdentsWithScope{
|
||||
LabelScope: fleet.LabelScopeExcludeAny,
|
||||
ByName: map[string]fleet.LabelIdent{
|
||||
label2.Name: {LabelName: label2.Name, LabelID: label2.ID},
|
||||
label3.Name: {LabelName: label3.Name, LabelID: label3.ID},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now host has label1, label2
|
||||
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID}))
|
||||
host.LabelUpdatedAt = time.Now()
|
||||
err = ds.UpdateHost(ctx, host)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// List should be back to just installer1
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software, installer2.Filename)
|
||||
|
||||
// installer1 is still in scope
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// installer2 is out of scope, because host has label2
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID2, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, scoped)
|
||||
|
||||
// Add an installer. No label yet.
|
||||
installer3 := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
UninstallScript: "goodbye",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: "storage3",
|
||||
Filename: "file3",
|
||||
Title: "file3",
|
||||
Version: "3.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
BundleIdentifier: "bi3",
|
||||
Platform: "darwin",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
}
|
||||
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer3)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
expectedInstallers[installer3.Filename] = &fleet.SoftwarePackageOrApp{
|
||||
Name: installer3.Filename,
|
||||
Version: installer3.Version,
|
||||
SelfService: ptr.Bool(false),
|
||||
}
|
||||
|
||||
// Add a new label and apply it to the installer. There are no hosts with this label.
|
||||
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
|
||||
LabelScope: fleet.LabelScopeExcludeAny,
|
||||
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should have [installerID1, installerID3], but the exclude any label has
|
||||
// no results for this host yet, so it's just installerID1 for now.
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, software, 1)
|
||||
|
||||
// installer1 is still in scope
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// installer3 is not in scope yet, because label is "exclude any" and host doesn't have results
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, scoped)
|
||||
|
||||
// mark as if label had been reported (but host is still not a member)
|
||||
host.LabelUpdatedAt = time.Now()
|
||||
err = ds.UpdateHost(ctx, host)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// now has 2 software (installer1 and 3)
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software, installer2.Filename)
|
||||
|
||||
// installer1 is still in scope
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// installer3 is in scope, because label is "exclude any" and host doesn't have the label
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// Now include hosts with label4. No host has this label, so we shouldn't see installerID3 anymore.
|
||||
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
|
||||
LabelScope: fleet.LabelScopeIncludeAny,
|
||||
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should have [installerID1]
|
||||
software, _, err = ds.ListHostSoftware(ctx, host, opts)
|
||||
require.NoError(t, err)
|
||||
checkSoftware(software, installer2.Filename, installer3.Filename)
|
||||
|
||||
// installer1 is still in scope
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, scoped)
|
||||
|
||||
// installer3 is not in scope
|
||||
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, scoped)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,11 +301,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
|
|||
|
||||
// create a software installer not installed on any host
|
||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer1)
|
||||
|
|
@ -316,11 +317,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
// create a software installer with an install request on host1
|
||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false, nil)
|
||||
|
|
@ -639,6 +641,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
|
|||
BundleIdentifier: "foo.bar",
|
||||
TeamID: &team1.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer1)
|
||||
|
|
@ -649,12 +652,13 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
// create a software installer for team2
|
||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
TeamID: &team2.ID,
|
||||
UserID: user1.ID,
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
TeamID: &team2.ID,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer2)
|
||||
|
|
@ -876,20 +880,22 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
|
|||
|
||||
// create a couple software installers not installed on any host
|
||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer1)
|
||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer2)
|
||||
|
|
@ -981,20 +987,22 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore
|
|||
|
||||
// create 2 software installers
|
||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer1",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer1.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer1)
|
||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
Title: "installer2",
|
||||
Source: "apps",
|
||||
InstallScript: "echo",
|
||||
Filename: "installer2.pkg",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer2)
|
||||
|
|
@ -1178,6 +1186,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) {
|
|||
Filename: "foobar.pkg",
|
||||
TeamID: nil,
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -1361,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
|
|||
Filename: "installer1.pkg",
|
||||
BundleIdentifier: "com.foo.installer1",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer1)
|
||||
|
|
@ -1372,6 +1382,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
|
|||
TeamID: &tm.ID,
|
||||
BundleIdentifier: "com.foo.installer2",
|
||||
UserID: user1.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, installer2)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
|
|||
tc := config.TestConfig()
|
||||
tc.Osquery.MinSoftwareLastOpenedAtDiff = defaultMinLastOpenedAtDiff
|
||||
|
||||
// TODO: for some reason we never log datastore messages when running integration tests, why?
|
||||
//
|
||||
// Changes below assume that we want to follows the same pattern as the rest of the codebase.
|
||||
dslogger := log.NewLogfmtLogger(os.Stdout)
|
||||
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
|
||||
dslogger = log.NewNopLogger()
|
||||
}
|
||||
|
||||
// set SQL mode to ANSI, as it's a special mode equivalent to:
|
||||
// REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, and
|
||||
// ONLY_FULL_GROUP_BY
|
||||
|
|
@ -76,7 +84,7 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
|
|||
// standard SQL.
|
||||
//
|
||||
// https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi
|
||||
ds, err := New(cfg, clock.NewMockClock(), Logger(log.NewNopLogger()), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc))
|
||||
ds, err := New(cfg, clock.NewMockClock(), Logger(dslogger), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc))
|
||||
require.Nil(t, err)
|
||||
|
||||
if opts.DummyReplica {
|
||||
|
|
|
|||
|
|
@ -1663,13 +1663,20 @@ func (a ActivityTypeUninstalledSoftware) Documentation() (activity, details, det
|
|||
}`
|
||||
}
|
||||
|
||||
type ActivitySoftwareLabel struct {
|
||||
Name string `json:"name"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type ActivityTypeAddedSoftware struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareTitleID uint `json:"software_title_id"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareTitleID uint `json:"software_title_id"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityTypeAddedSoftware) ActivityName() string {
|
||||
|
|
@ -1683,22 +1690,36 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) {
|
|||
- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team." +
|
||||
- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
|
||||
- "self_service": Whether the software is available for installation by the end user.
|
||||
- "software_title_id": ID of the added software title.`, `{
|
||||
- "software_title_id": ID of the added software title.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
|
||||
"software_title": "Falcon.app",
|
||||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true,
|
||||
"software_title_id": 2234
|
||||
"software_title_id": 2234,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
type ActivityTypeEditedSoftware struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage *string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage *string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityTypeEditedSoftware) ActivityName() string {
|
||||
|
|
@ -1711,21 +1732,35 @@ func (a ActivityTypeEditedSoftware) Documentation() (string, string, string) {
|
|||
- "software_package": Filename of the installer as of this update (including if unchanged).
|
||||
- "team_name": Name of the team on which this software was updated.` + " `null` " + `if it was updated on no team.
|
||||
- "team_id": The ID of the team on which this software was updated.` + " `null` " + `if it was updated on no team.
|
||||
- "self_service": Whether the software is available for installation by the end user.`, `{
|
||||
- "self_service": Whether the software is available for installation by the end user.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
|
||||
"software_title": "Falcon.app",
|
||||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true
|
||||
"self_service": true,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
type ActivityTypeDeletedSoftware struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwarePackage string `json:"software_package"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityTypeDeletedSoftware) ActivityName() string {
|
||||
|
|
@ -1738,12 +1773,24 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) {
|
|||
- "software_package": Filename of the installer.
|
||||
- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team.
|
||||
- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
|
||||
- "self_service": Whether the software was available for installation by the end user.`, `{
|
||||
- "self_service": Whether the software was available for installation by the end user.
|
||||
- "labels_include_any": Target hosts that have any label in the array.
|
||||
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
|
||||
"software_title": "Falcon.app",
|
||||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123,
|
||||
"self_service": true
|
||||
"self_service": true,
|
||||
"labels_include_any": [
|
||||
{
|
||||
"name": "Engineering",
|
||||
"id": 12
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"id": 17
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -609,6 +609,10 @@ type Datastore interface {
|
|||
|
||||
ListHostSoftware(ctx context.Context, host *Host, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
|
||||
|
||||
// IsSoftwareInstallerLabelScoped returns whether or not the given installerID is scoped to the
|
||||
// given host ID by labels.
|
||||
IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error)
|
||||
|
||||
// SetHostSoftwareInstallResult records the result of a software installation
|
||||
// attempt on the host.
|
||||
SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) error
|
||||
|
|
|
|||
|
|
@ -194,3 +194,30 @@ func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []stri
|
|||
|
||||
return missingLabels
|
||||
}
|
||||
|
||||
// LabelIdent is a simple struct to hold the ID and Name of a label
|
||||
type LabelIdent struct {
|
||||
LabelID uint
|
||||
LabelName string
|
||||
}
|
||||
|
||||
// LabelScope identifies the manner by which labels may be used to scope entities, such as MDM
|
||||
// profiles and software installers, to subsets of hosts.
|
||||
type LabelScope string
|
||||
|
||||
const (
|
||||
// LabelScopeExcludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
|
||||
// installers) should NOT be applied to a host if the host is a mamber of any of the associated labels.
|
||||
LabelScopeExcludeAny LabelScope = "exclude_any"
|
||||
// LabelScopeIncludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
|
||||
// installers) should be applied to a host that if the host is a member of all of the associated labels.
|
||||
LabelScopeIncludeAny LabelScope = "include_any"
|
||||
// LabelScopeIncludeAll indicates that a label-scoped entity (e.g., MDM profiles, software
|
||||
// installers) should be applied to a host if the host is a member of all of the associated labels.
|
||||
LabelScopeIncludeAll LabelScope = "include_all"
|
||||
)
|
||||
|
||||
type LabelIdentsWithScope struct {
|
||||
LabelScope LabelScope
|
||||
ByName map[string]LabelIdent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -380,15 +380,20 @@ type ScriptPayload struct {
|
|||
}
|
||||
|
||||
type SoftwareInstallerPayload struct {
|
||||
URL string `json:"url"`
|
||||
PreInstallQuery string `json:"pre_install_query"`
|
||||
InstallScript string `json:"install_script"`
|
||||
UninstallScript string `json:"uninstall_script"`
|
||||
PostInstallScript string `json:"post_install_script"`
|
||||
SelfService bool `json:"self_service"`
|
||||
FleetMaintained bool `json:"-"`
|
||||
Filename string `json:"-"`
|
||||
InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
|
||||
URL string `json:"url"`
|
||||
PreInstallQuery string `json:"pre_install_query"`
|
||||
InstallScript string `json:"install_script"`
|
||||
UninstallScript string `json:"uninstall_script"`
|
||||
PostInstallScript string `json:"post_install_script"`
|
||||
SelfService bool `json:"self_service"`
|
||||
FleetMaintained bool `json:"-"`
|
||||
Filename string `json:"-"`
|
||||
InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
// ValidatedLabels is a struct that contains the validated labels for the
|
||||
// software installer. It is nil if the labels have not been validated.
|
||||
ValidatedLabels *LabelIdentsWithScope
|
||||
}
|
||||
|
||||
type HostLockWipeStatus struct {
|
||||
|
|
|
|||
|
|
@ -259,6 +259,11 @@ type Service interface {
|
|||
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
|
||||
ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error)
|
||||
|
||||
// BatchValidateLabels validates that each of the provided label names exists. The returned map
|
||||
// is keyed by label name. Caller must ensure that appropirate authorization checks are
|
||||
// performed prior to calling this method.
|
||||
BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]LabelIdent, error)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// QueryService
|
||||
|
||||
|
|
@ -649,7 +654,7 @@ type Service interface {
|
|||
|
||||
// BatchSetSoftwareInstallers asynchronously replaces the software installers for a specified team.
|
||||
// Returns a request UUID that can be used to track an ongoing batch request (with GetBatchSetSoftwareInstallersResult).
|
||||
BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) (string, error)
|
||||
BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []*SoftwareInstallerPayload, dryRun bool) (string, error)
|
||||
// GetBatchSetSoftwareInstallersResult polls for the status of a batch-apply started by BatchSetSoftwareInstallers.
|
||||
// Return values:
|
||||
// - 'status': status of the batch-apply which can be "processing", "completed" or "failed".
|
||||
|
|
@ -1163,7 +1168,7 @@ type Service interface {
|
|||
// Fleet-maintained apps
|
||||
|
||||
// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
|
||||
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error)
|
||||
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error)
|
||||
// ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team
|
||||
ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
|
||||
// GetFleetMaintainedApp returns a Fleet-maintained app by ID
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ type SoftwareInstaller struct {
|
|||
// AutomaticInstallPolicies is the list of policies that trigger automatic
|
||||
// installation of this software.
|
||||
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
|
||||
// LablesIncludeAny is the list of "include any" labels for this software installer (if not nil).
|
||||
LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"`
|
||||
// LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil).
|
||||
LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"`
|
||||
}
|
||||
|
||||
// SoftwarePackageResponse is the response type used when applying software by batch.
|
||||
|
|
@ -331,7 +335,12 @@ type UploadSoftwareInstallerPayload struct {
|
|||
PackageIDs []string
|
||||
UninstallScript string
|
||||
Extension string
|
||||
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
|
||||
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
|
||||
LabelsIncludeAny []string // names of "include any" labels
|
||||
LabelsExcludeAny []string // names of "exclude any" labels
|
||||
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
|
||||
// is nil if the labels have not been validated.
|
||||
ValidatedLabels *LabelIdentsWithScope
|
||||
}
|
||||
|
||||
type UpdateSoftwareInstallerPayload struct {
|
||||
|
|
@ -356,6 +365,11 @@ type UpdateSoftwareInstallerPayload struct {
|
|||
Filename string
|
||||
Version string
|
||||
PackageIDs []string
|
||||
LabelsIncludeAny []string // names of "include any" labels
|
||||
LabelsExcludeAny []string // names of "exclude any" labels
|
||||
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
|
||||
// can be nil if the labels have not been validated or if the labels are not being updated.
|
||||
ValidatedLabels *LabelIdentsWithScope
|
||||
}
|
||||
|
||||
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
|
||||
|
|
@ -452,6 +466,8 @@ type SoftwarePackageSpec struct {
|
|||
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
|
||||
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
|
||||
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
|
||||
// ReferencedYamlPath is the resolved path of the file used to fill the
|
||||
// software package. Only present after parsing a GitOps file on the fleetctl
|
||||
|
|
@ -616,3 +632,18 @@ func NewTempFileReader(from io.Reader, tempDirFn func() string) (*TempFileReader
|
|||
}
|
||||
return tfr, nil
|
||||
}
|
||||
|
||||
// SoftwareScopeLabel represents the many-to-many relationship between
|
||||
// software titles and labels.
|
||||
//
|
||||
// NOTE: json representation of the fields is a bit awkward to match the
|
||||
// required API response, as this struct is returned within software title details.
|
||||
//
|
||||
// NOTE: depending on how/where this struct is used, fields MAY BE
|
||||
// UNRELIABLE insofar as they represent default, empty values.
|
||||
type SoftwareScopeLabel struct {
|
||||
LabelName string `db:"label_name" json:"name"`
|
||||
LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing)
|
||||
Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases)
|
||||
TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,6 +451,8 @@ type ListCVEsFunc func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMe
|
|||
|
||||
type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error)
|
||||
|
||||
type IsSoftwareInstallerLabelScopedFunc func(ctx context.Context, installerID uint, hostID uint) (bool, error)
|
||||
|
||||
type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error
|
||||
|
||||
type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error)
|
||||
|
|
@ -1827,6 +1829,9 @@ type DataStore struct {
|
|||
ListHostSoftwareFunc ListHostSoftwareFunc
|
||||
ListHostSoftwareFuncInvoked bool
|
||||
|
||||
IsSoftwareInstallerLabelScopedFunc IsSoftwareInstallerLabelScopedFunc
|
||||
IsSoftwareInstallerLabelScopedFuncInvoked bool
|
||||
|
||||
SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc
|
||||
SetHostSoftwareInstallResultFuncInvoked bool
|
||||
|
||||
|
|
@ -4430,6 +4435,13 @@ func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts
|
|||
return s.ListHostSoftwareFunc(ctx, host, opts)
|
||||
}
|
||||
|
||||
func (s *DataStore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID uint, hostID uint) (bool, error) {
|
||||
s.mu.Lock()
|
||||
s.IsSoftwareInstallerLabelScopedFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.IsSoftwareInstallerLabelScopedFunc(ctx, installerID, hostID)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error {
|
||||
s.mu.Lock()
|
||||
s.SetHostSoftwareInstallResultFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -1007,6 +1007,8 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
|
|||
PostInstallScript: string(pc),
|
||||
UninstallScript: string(us),
|
||||
InstallDuringSetup: installDuringSetup,
|
||||
LabelsIncludeAny: si.LabelsIncludeAny,
|
||||
LabelsExcludeAny: si.LabelsExcludeAny,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
|
||||
// Fleet-maintained apps
|
||||
ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{})
|
||||
ue.PATCH("/api/_version_/fleet/software/fleet_maintained_apps", editFleetMaintainedAppEndpoint, editFleetMaintainedAppRequest{})
|
||||
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{})
|
||||
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps/{app_id}", getFleetMaintainedApp, getFleetMaintainedAppRequest{})
|
||||
|
||||
|
|
|
|||
|
|
@ -11990,14 +11990,15 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
|
|||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
sw1, _, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install foo",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "foo.pkg",
|
||||
Title: "foo",
|
||||
Source: "apps",
|
||||
Version: "0.0.1",
|
||||
UserID: adminUser.ID,
|
||||
InstallScript: "install foo",
|
||||
InstallerFile: tfr1,
|
||||
StorageID: uuid.NewString(),
|
||||
Filename: "foo.pkg",
|
||||
Title: "foo",
|
||||
Source: "apps",
|
||||
Version: "0.0.1",
|
||||
UserID: adminUser.ID,
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
|
@ -10461,6 +10462,69 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
|
|||
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
|
||||
require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version)
|
||||
|
||||
// =========================================
|
||||
// test label scoping
|
||||
// =========================================
|
||||
|
||||
// TODO(JVE): remove/update this once the API is in place
|
||||
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err = q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
|
||||
siID, labelID, exclude,
|
||||
)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
var installerID uint
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
|
||||
})
|
||||
require.NotEmpty(t, installerID)
|
||||
|
||||
// create some labels
|
||||
var labelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: "label1",
|
||||
Hosts: []string{host.Hostname},
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: "label2",
|
||||
Hosts: []string{host.Hostname},
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
|
||||
// Set to "exclude any". Installer should be missing from the response for both host details and
|
||||
// for self service
|
||||
updateInstallerLabel(installerID, labelResp.Label.ID, true)
|
||||
getHostSw = getHostSoftwareResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
|
||||
require.Empty(t, getHostSw.Software)
|
||||
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
|
||||
getDeviceSw = getDeviceSoftwareResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, getDeviceSw.Software)
|
||||
|
||||
// Set to "include any". Installer should be in response.
|
||||
updateInstallerLabel(installerID, labelResp.Label.ID, false)
|
||||
getHostSw = getHostSoftwareResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
|
||||
require.Len(t, getHostSw.Software, 1)
|
||||
require.Equal(t, getHostSw.Software[0].Name, "ruby")
|
||||
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
|
||||
getDeviceSw = getDeviceSoftwareResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, getDeviceSw.Software, 1)
|
||||
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
|
||||
|
||||
// request installation on the host
|
||||
var installResp installSoftwareResponse
|
||||
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
|
||||
|
|
@ -10653,21 +10717,63 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID)
|
||||
require.NotZero(t, meta.UploadedAt)
|
||||
|
||||
// get metadata by team and title ID so we can check labels
|
||||
meta2, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(context.Background(), payload.TeamID, *meta.TitleID, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// check labels include any
|
||||
require.Len(t, meta2.LabelsIncludeAny, len(payload.LabelsIncludeAny))
|
||||
byName := make(map[string]struct{}, len(meta2.LabelsIncludeAny))
|
||||
for _, l := range meta2.LabelsIncludeAny {
|
||||
byName[l.LabelName] = struct{}{}
|
||||
require.Equal(t, *meta2.TitleID, l.TitleID)
|
||||
require.False(t, l.Exclude)
|
||||
}
|
||||
require.Len(t, byName, len(payload.LabelsIncludeAny))
|
||||
for _, l := range payload.LabelsIncludeAny {
|
||||
_, ok := byName[l]
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
// check labels exclude any
|
||||
require.Len(t, meta2.LabelsExcludeAny, len(payload.LabelsExcludeAny))
|
||||
byName = make(map[string]struct{}, len(meta2.LabelsExcludeAny))
|
||||
for _, l := range meta2.LabelsExcludeAny {
|
||||
byName[l.LabelName] = struct{}{}
|
||||
require.Equal(t, *meta2.TitleID, l.TitleID)
|
||||
require.True(t, l.Exclude)
|
||||
}
|
||||
require.Len(t, byName, len(payload.LabelsExcludeAny))
|
||||
for _, l := range payload.LabelsExcludeAny {
|
||||
_, ok := byName[l]
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
return meta.InstallerID, *meta.TitleID
|
||||
}
|
||||
|
||||
t.Run("upload no team software installer", func(t *testing.T) {
|
||||
// status is reflected in list hosts responses and counts when filtering by software title and status
|
||||
// create a label to test also the counts per label with the software install status filter
|
||||
var labelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: t.Name(),
|
||||
Query: "select 1",
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
|
||||
payload := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "some install script",
|
||||
PreInstallQuery: "some pre install query",
|
||||
PostInstallScript: "some post install script",
|
||||
Filename: "ruby.deb",
|
||||
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
|
||||
Title: "ruby",
|
||||
Version: "1:2.5.1",
|
||||
Source: "deb_packages",
|
||||
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
|
||||
Platform: "linux",
|
||||
Title: "ruby",
|
||||
Version: "1:2.5.1",
|
||||
Source: "deb_packages",
|
||||
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
|
||||
Platform: "linux",
|
||||
LabelsIncludeAny: []string{t.Name()},
|
||||
}
|
||||
|
||||
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
||||
|
|
@ -10676,12 +10782,75 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
_, titleID := checkSoftwareInstaller(t, payload)
|
||||
|
||||
// check activity
|
||||
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false, "software_title_id": %d}`, titleID)
|
||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
|
||||
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
|
||||
"team_id": null, "self_service": false, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}]}`,
|
||||
titleID, labelResp.Label.ID, t.Name())
|
||||
s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
|
||||
|
||||
// upload again fails
|
||||
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
|
||||
|
||||
// patch the software installer to change the labels
|
||||
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
|
||||
"team_id": {"0"},
|
||||
"labels_exclude_any": {t.Name()},
|
||||
})
|
||||
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
|
||||
expectedPayload := *payload
|
||||
expectedPayload.LabelsIncludeAny = nil
|
||||
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name}
|
||||
checkSoftwareInstaller(t, &expectedPayload)
|
||||
|
||||
// Create a host and assign the label to it
|
||||
host := createOrbitEnrolledHost(t, "linux", "label_host", s.ds)
|
||||
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to install. Should fail because label is "exclude any"
|
||||
resp := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
|
||||
|
||||
// patch the software installer again but this time change the pre install query and leave the labels as is
|
||||
body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
|
||||
"team_id": {"0"},
|
||||
"pre_install_query": {"some other pre install query"},
|
||||
})
|
||||
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
|
||||
expectedPayload.PreInstallQuery = "some other pre install query"
|
||||
expectedPayload.LabelsIncludeAny = nil // no change
|
||||
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} // no change
|
||||
checkSoftwareInstaller(t, &expectedPayload)
|
||||
|
||||
// update the label to be "include any". This should allow for the installation to happen.
|
||||
var b3 bytes.Buffer
|
||||
w3 := multipart.NewWriter(&b3)
|
||||
require.NoError(t, w3.WriteField("team_id", "0"))
|
||||
require.NoError(t, w3.WriteField("pre_install_query", "some other pre install query"))
|
||||
require.NoError(t, w3.WriteField("labels_include_any", labelResp.Label.Name))
|
||||
w3.Close()
|
||||
headers = map[string]string{
|
||||
"Content-Type": w3.FormDataContentType(),
|
||||
"Accept": "application/json",
|
||||
"Authorization": fmt.Sprintf("Bearer %s", s.token),
|
||||
}
|
||||
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b3.Bytes(), http.StatusOK, headers)
|
||||
expectedPayload.PreInstallQuery = "some other pre install query"
|
||||
expectedPayload.LabelsIncludeAny = []string{labelResp.Label.Name}
|
||||
expectedPayload.LabelsExcludeAny = nil
|
||||
checkSoftwareInstaller(t, &expectedPayload)
|
||||
|
||||
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted)
|
||||
|
||||
// update the installer succeeds
|
||||
body, headers = generateMultipartRequest(t, "software",
|
||||
"", []byte{}, s.token, map[string][]string{"self_service": {"true"}, "team_id": {"0"}})
|
||||
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
|
||||
|
||||
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
|
||||
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`,
|
||||
labelResp.Label.ID, labelResp.Label.Name)
|
||||
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
|
||||
|
||||
// orbit-downloading fails with invalid orbit node key
|
||||
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
|
||||
InstallerID: 123,
|
||||
|
|
@ -10696,6 +10865,10 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
|
||||
// delete from team 0 succeeds
|
||||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0")
|
||||
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
|
||||
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`,
|
||||
labelResp.Label.ID, labelResp.Label.Name)
|
||||
s.lastActivityMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), activityData, 0)
|
||||
})
|
||||
|
||||
t.Run("create team software installer", func(t *testing.T) {
|
||||
|
|
@ -10912,7 +11085,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
|
||||
// check activity
|
||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(),
|
||||
`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`, 0)
|
||||
`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": true}`, 0)
|
||||
|
||||
// download the installer, not found anymore
|
||||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0))
|
||||
|
|
@ -11243,6 +11416,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
|
|||
|
||||
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
// non-existent team
|
||||
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo")
|
||||
|
|
@ -11255,13 +11429,13 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
require.NoError(t, err)
|
||||
|
||||
// software with a bad URL
|
||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||
{URL: "."},
|
||||
}
|
||||
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
|
||||
|
||||
// software with a too big URL
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 4000-23)},
|
||||
}
|
||||
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
|
||||
|
|
@ -11284,7 +11458,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
t.Cleanup(srv.Close)
|
||||
|
||||
// do a request with a URL that returns a 404.
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: srv.URL + "/not_found.pkg"},
|
||||
}
|
||||
var batchResponse batchSetSoftwareInstallersResponse
|
||||
|
|
@ -11295,7 +11469,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
|
||||
// do a request with a valid URL
|
||||
rubyURL := srv.URL + "/ruby.deb"
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: rubyURL},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
|
|
@ -11306,7 +11480,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
require.NotNil(t, packages[0].TeamID)
|
||||
require.Equal(t, tm.ID, *packages[0].TeamID)
|
||||
|
||||
softwareToInstallBadSecret := []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstallBadSecret := []*fleet.SoftwareInstallerPayload{
|
||||
{
|
||||
URL: rubyURL,
|
||||
InstallScript: "echo $FLEET_SECRET_INVALID",
|
||||
|
|
@ -11379,7 +11553,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
require.Equal(t, titlesResp, newTitlesResp)
|
||||
|
||||
// empty payload cleans the software items
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{}
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
|
||||
require.Empty(t, packages)
|
||||
|
|
@ -11392,7 +11566,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
//////////////////////////
|
||||
// Do a request with a valid URL with no team
|
||||
//////////////////////////
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: rubyURL},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
|
||||
|
|
@ -11432,8 +11606,45 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
|
||||
require.Equal(t, titlesResp, newTitlesResp)
|
||||
|
||||
// create some labels A and B
|
||||
lblA, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "A"})
|
||||
require.NoError(t, err)
|
||||
lblB, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "B"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// providing both labels include/exclude results in an error
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}, LabelsExcludeAny: []string{lblB.Name}},
|
||||
}
|
||||
res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`)
|
||||
|
||||
// providing a non-existing label results in an error
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: rubyURL, LabelsIncludeAny: []string{"no-such-label"}},
|
||||
}
|
||||
res = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(res.Body), `some or all the labels provided don't exist`)
|
||||
|
||||
// valid installer scoped by label
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 1)
|
||||
require.NotNil(t, packages[0].TitleID)
|
||||
require.Equal(t, rubyURL, packages[0].URL)
|
||||
require.Nil(t, packages[0].TeamID)
|
||||
meta, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, meta.LabelsExcludeAny)
|
||||
require.Len(t, meta.LabelsIncludeAny, 1)
|
||||
require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID)
|
||||
require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName)
|
||||
|
||||
// empty payload cleans the software items
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{}
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
|
||||
require.Empty(t, packages)
|
||||
|
|
@ -11505,7 +11716,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
|||
t.Cleanup(srv.Close)
|
||||
|
||||
// set up software to install
|
||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||
{URL: srv.URL},
|
||||
}
|
||||
var batchResponse batchSetSoftwareInstallersResponse
|
||||
|
|
@ -11593,7 +11804,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
|||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
|
||||
|
||||
// update pre-install query
|
||||
withUpdatedPreinstallQuery := []fleet.SoftwareInstallerPayload{
|
||||
withUpdatedPreinstallQuery := []*fleet.SoftwareInstallerPayload{
|
||||
{URL: srv.URL, PreInstallQuery: "SELECT * FROM os_version"},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedPreinstallQuery}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
|
|
@ -11638,7 +11849,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
|||
require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status)
|
||||
|
||||
// update install script
|
||||
withUpdatedInstallScript := []fleet.SoftwareInstallerPayload{
|
||||
withUpdatedInstallScript := []*fleet.SoftwareInstallerPayload{
|
||||
{URL: srv.URL, InstallScript: "apt install ruby"},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
|
|
@ -11706,7 +11917,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
|||
require.Equal(t, fleet.SoftwareUninstallPending, *afterPreinstallHostResp.Software[0].Status)
|
||||
|
||||
// delete all installers
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []fleet.SoftwareInstallerPayload{}}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{}}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 0)
|
||||
|
||||
|
|
@ -11766,7 +11977,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
|||
t.Cleanup(srv.Close)
|
||||
|
||||
// team1 has ruby.deb
|
||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||
{
|
||||
URL: srv.URL + "/ruby.deb",
|
||||
},
|
||||
|
|
@ -11781,7 +11992,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
|||
require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL)
|
||||
|
||||
// team2 has dummy_installer.pkg and ruby.deb.
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||
{
|
||||
URL: srv.URL + "/dummy_installer.pkg",
|
||||
},
|
||||
|
|
@ -11831,7 +12042,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
|||
}, http.StatusOK, &mtplr)
|
||||
|
||||
// Get rid of all installers in team1.
|
||||
softwareToInstall = []fleet.SoftwareInstallerPayload{}
|
||||
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
|
||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
|
||||
require.Len(t, packages, 0)
|
||||
|
|
@ -12389,6 +12600,17 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
|||
token := "secret_token"
|
||||
createDeviceTokenForHost(t, s.ds, host1.ID, token)
|
||||
|
||||
// Create a label and assign it to the host
|
||||
var labelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: t.Name(),
|
||||
Query: "select 1",
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
|
||||
err := s.ds.RecordLabelQueryExecutions(context.Background(), host1, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
payloadNoSS := &fleet.UploadSoftwareInstallerPayload{
|
||||
PreInstallQuery: "SELECT 1",
|
||||
InstallScript: "install",
|
||||
|
|
@ -12407,6 +12629,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
|||
Filename: "emacs.deb",
|
||||
Title: "emacs",
|
||||
SelfService: true,
|
||||
LabelsIncludeAny: []string{labelResp.Label.Name},
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
|
||||
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
|
||||
|
|
@ -12416,7 +12639,23 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
|||
errMsg := extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "Software title is not available through self-service")
|
||||
|
||||
// request self-install of software that allows it
|
||||
// Add an installer with an exclude any label. Installation attempt should fail.
|
||||
payloadLabelSS := &fleet.UploadSoftwareInstallerPayload{
|
||||
PreInstallQuery: "SELECT 42",
|
||||
InstallScript: "install again",
|
||||
PostInstallScript: "echo bye",
|
||||
Filename: "vim.deb",
|
||||
Title: "vim",
|
||||
SelfService: true,
|
||||
LabelsExcludeAny: []string{labelResp.Label.Name},
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, payloadLabelSS, http.StatusOK, "")
|
||||
titleIDLabelSS := getSoftwareTitleID(t, s.ds, payloadLabelSS.Title, "deb_packages")
|
||||
|
||||
resp := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDLabelSS), nil, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
|
||||
|
||||
// request self-install of software that allows it (is self-service + label scoped)
|
||||
s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDSS), nil, http.StatusAccepted)
|
||||
|
||||
// it shows up as "self-installed" in the upcoming activities of the host
|
||||
|
|
@ -12426,7 +12665,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
|||
require.Nil(t, listUpcomingAct.Activities[0].ActorID)
|
||||
|
||||
var details fleet.ActivityTypeInstalledSoftware
|
||||
err := json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
|
||||
err = json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, host1.ID, details.HostID)
|
||||
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
|
||||
|
|
@ -14814,6 +15053,184 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
|
|||
require.Nil(t, hostVanillaOsquery5Team1LastInstall)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallersLabelScoping() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now().Add(-1 * time.Minute),
|
||||
OsqueryHostID: ptr.String(t.Name()),
|
||||
NodeKey: ptr.String(t.Name()),
|
||||
UUID: uuid.New().String(),
|
||||
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
|
||||
Platform: "linux",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
orbitKey := setOrbitEnrollment(t, host, s.ds)
|
||||
host.OrbitNodeKey = &orbitKey
|
||||
|
||||
// Create a few labels
|
||||
var newLabelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: uuid.NewString(),
|
||||
Query: "SELECT 1",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl1 := newLabelResp.Label
|
||||
|
||||
newLabelResp = createLabelResponse{}
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: uuid.NewString(),
|
||||
Query: "SELECT 2",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl2 := newLabelResp.Label
|
||||
|
||||
newLabelResp = createLabelResponse{}
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: uuid.NewString(),
|
||||
Query: "SELECT 3",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl3 := newLabelResp.Label
|
||||
|
||||
// Add label1 and label2 to the host
|
||||
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// upload software. Add label1 and label3 as "exclude any" labels.
|
||||
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "some deb install script",
|
||||
Filename: "ruby.deb",
|
||||
TeamID: nil,
|
||||
LabelsExcludeAny: []string{lbl1.Name, lbl3.Name},
|
||||
Platform: "linux",
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
|
||||
|
||||
// Get software title ID of the uploaded installer.
|
||||
resp := listSoftwareTitlesResponse{}
|
||||
s.DoJSON(
|
||||
"GET", "/api/latest/fleet/software/titles",
|
||||
listSoftwareTitlesRequest{},
|
||||
http.StatusOK, &resp,
|
||||
"query", "ruby",
|
||||
"team_id", "0",
|
||||
)
|
||||
require.Len(t, resp.SoftwareTitles, 1)
|
||||
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
|
||||
rubyDebTitleID := resp.SoftwareTitles[0].ID
|
||||
|
||||
var rubyDetail getSoftwareTitleResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail)
|
||||
require.NotNil(t, rubyDetail.SoftwareTitle)
|
||||
require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage)
|
||||
rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID
|
||||
|
||||
policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
|
||||
Name: "policy1",
|
||||
Query: "SELECT 1;",
|
||||
Platform: "linux",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mtplr := modifyTeamPolicyResponse{}
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{
|
||||
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
|
||||
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
|
||||
},
|
||||
}, http.StatusOK, &mtplr)
|
||||
|
||||
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, host1LastInstall)
|
||||
|
||||
// Send back a failed result for the policy.
|
||||
distributedResp := submitDistributedQueryResultsResponse{}
|
||||
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
|
||||
host,
|
||||
map[uint]*bool{
|
||||
policy1.ID: ptr.Bool(false),
|
||||
},
|
||||
), http.StatusOK, &distributedResp)
|
||||
err = s.ds.UpdateHostPolicyCounts(ctx)
|
||||
require.NoError(t, err)
|
||||
policy1, err = s.ds.Policy(ctx, policy1.ID)
|
||||
require.NoError(t, err)
|
||||
// Because the installer is not in scope, we do not mark the policy as failed.
|
||||
require.Equal(t, uint(0), policy1.PassingHostCount)
|
||||
require.Equal(t, uint(0), policy1.FailingHostCount)
|
||||
|
||||
// No installation attempt, because we skipped due to label scoping
|
||||
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, host1LastInstall)
|
||||
|
||||
vimPayload := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "some deb install script",
|
||||
Filename: "vim.deb",
|
||||
TeamID: nil,
|
||||
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
|
||||
Platform: "linux",
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, vimPayload, http.StatusOK, "")
|
||||
|
||||
resp = listSoftwareTitlesResponse{}
|
||||
s.DoJSON(
|
||||
"GET", "/api/latest/fleet/software/titles",
|
||||
listSoftwareTitlesRequest{},
|
||||
http.StatusOK, &resp,
|
||||
"query", "vim",
|
||||
"team_id", "0",
|
||||
)
|
||||
require.Len(t, resp.SoftwareTitles, 1)
|
||||
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
|
||||
vimTitleID := resp.SoftwareTitles[0].ID
|
||||
|
||||
var vimDetail getSoftwareTitleResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", vimTitleID), nil, http.StatusOK, &vimDetail)
|
||||
require.NotNil(t, vimDetail.SoftwareTitle)
|
||||
require.NotNil(t, vimDetail.SoftwareTitle.SoftwarePackage)
|
||||
vimInstallerID := vimDetail.SoftwareTitle.SoftwarePackage.InstallerID
|
||||
|
||||
policy2, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
|
||||
Name: "policy2",
|
||||
Query: "SELECT 2;",
|
||||
Platform: "linux",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mtplr = modifyTeamPolicyResponse{}
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy2.ID), modifyTeamPolicyRequest{
|
||||
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
|
||||
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vimTitleID},
|
||||
},
|
||||
}, http.StatusOK, &mtplr)
|
||||
|
||||
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, host1LastInstall)
|
||||
|
||||
distributedResp = submitDistributedQueryResultsResponse{}
|
||||
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
|
||||
host,
|
||||
map[uint]*bool{
|
||||
policy2.ID: ptr.Bool(false),
|
||||
},
|
||||
), http.StatusOK, &distributedResp)
|
||||
err = s.ds.UpdateHostPolicyCounts(ctx)
|
||||
require.NoError(t, err)
|
||||
policy2, err = s.ds.Policy(ctx, policy2.ID)
|
||||
require.NoError(t, err)
|
||||
// Because the installer is in scope, we do mark the policy as failed.
|
||||
require.Equal(t, uint(0), policy2.PassingHostCount)
|
||||
require.Equal(t, uint(1), policy2.FailingHostCount)
|
||||
|
||||
// We have an installation attempt for vim, because it's in scope
|
||||
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, host1LastInstall)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
|
@ -15695,6 +16112,83 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
require.NotNil(t, policies[0].InstallSoftware)
|
||||
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
|
||||
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
|
||||
|
||||
// ===========================================================================================
|
||||
// Adding label-scoped FMA
|
||||
// ===========================================================================================
|
||||
|
||||
// Add some labels
|
||||
var newLabelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: t.Name() + "1",
|
||||
Platform: "darwin",
|
||||
Query: "SELECT 1",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl1 := newLabelResp.Label
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: t.Name() + "2",
|
||||
Platform: "darwin",
|
||||
Query: "SELECT 1",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl2 := newLabelResp.Label
|
||||
|
||||
// Add another FMA
|
||||
req = &addFleetMaintainedAppRequest{
|
||||
AppID: 6,
|
||||
SelfService: false,
|
||||
PreInstallQuery: "SELECT 1",
|
||||
InstallScript: "echo foo",
|
||||
PostInstallScript: "echo done",
|
||||
TeamID: ptr.Uint(0),
|
||||
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
|
||||
}
|
||||
|
||||
addMAResp = addFleetMaintainedAppResponse{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
|
||||
require.NoError(t, addMAResp.Err)
|
||||
require.NotEmpty(t, addMAResp.SoftwareTitleID)
|
||||
|
||||
// Get software title details
|
||||
titleResp = getSoftwareTitleResponse{}
|
||||
s.DoJSON(
|
||||
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
|
||||
getSoftwareTitleRequest{},
|
||||
http.StatusOK, &titleResp,
|
||||
"team_id", "0",
|
||||
)
|
||||
|
||||
require.NotNil(t, titleResp.SoftwareTitle)
|
||||
swTitle = titleResp.SoftwareTitle
|
||||
require.NotNil(t, swTitle.SoftwarePackage)
|
||||
require.Empty(t, swTitle.SoftwarePackage.LabelsExcludeAny)
|
||||
require.Len(t, swTitle.SoftwarePackage.LabelsIncludeAny, 2)
|
||||
gotNames := make(map[string]bool)
|
||||
for _, lbl := range swTitle.SoftwarePackage.LabelsIncludeAny {
|
||||
gotNames[lbl.LabelName] = true
|
||||
}
|
||||
require.True(t, gotNames[lbl1.Name])
|
||||
require.True(t, gotNames[lbl2.Name])
|
||||
|
||||
// Can't set non-existent label
|
||||
req = &addFleetMaintainedAppRequest{
|
||||
AppID: 7,
|
||||
SelfService: false,
|
||||
PreInstallQuery: "SELECT 1",
|
||||
InstallScript: "echo foo",
|
||||
PostInstallScript: "echo done",
|
||||
TeamID: ptr.Uint(0),
|
||||
LabelsIncludeAny: []string{"no-such-label"},
|
||||
}
|
||||
addMAResp = addFleetMaintainedAppResponse{}
|
||||
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(r.Body), "some or all the labels provided don't exist")
|
||||
|
||||
// Can't set both labels_include_any and labels_exclude_any
|
||||
req.LabelsIncludeAny = []string{lbl1.Name, lbl2.Name}
|
||||
req.LabelsExcludeAny = []string{lbl1.Name}
|
||||
addMAResp = addFleetMaintainedAppResponse{}
|
||||
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
|
||||
require.Contains(t, extractServerErrorText(r.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
|
||||
|
|
@ -15706,3 +16200,158 @@ func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
|
|||
errMsg := extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "Windows MDM is not enabled")
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestDeleteLabels() {
|
||||
t := s.T()
|
||||
|
||||
// create a couple labels
|
||||
var newLabelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: "TestDeleteLabels1",
|
||||
Platform: "darwin",
|
||||
Query: "SELECT 1",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl1 := newLabelResp.Label.ID
|
||||
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
|
||||
Name: "TestDeleteLabels2",
|
||||
Platform: "darwin",
|
||||
Query: "SELECT 2",
|
||||
}, http.StatusOK, &newLabelResp)
|
||||
lbl2 := newLabelResp.Label.ID
|
||||
|
||||
// create a software installer associated with the label
|
||||
installer := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install",
|
||||
Filename: "ruby.deb",
|
||||
SelfService: false,
|
||||
TeamID: nil,
|
||||
LabelsIncludeAny: []string{"TestDeleteLabels1"},
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, installer, http.StatusOK, "")
|
||||
|
||||
// try to delete the label associated with the installer
|
||||
res := s.Do("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1), nil, http.StatusUnprocessableEntity)
|
||||
errMsg := extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "foreign key constraint on labels: TestDeleteLabels1")
|
||||
|
||||
// try to delete a label that does not exist by id and name
|
||||
var delLabelResp deleteLabelByIDResponse
|
||||
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1+1000), nil, http.StatusNotFound, &delLabelResp)
|
||||
s.DoJSON("DELETE", "/api/v1/fleet/labels/no-such-label", nil, http.StatusNotFound, &delLabelResp)
|
||||
|
||||
// delete the unused label2
|
||||
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl2), nil, http.StatusOK, &delLabelResp)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestListHostSoftwareWithLabelScoping() {
|
||||
ctx := context.Background()
|
||||
t := s.T()
|
||||
|
||||
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
|
||||
|
||||
// Create software installers and corresponding host install requests.
|
||||
payload := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "install script",
|
||||
PreInstallQuery: "pre install query",
|
||||
PostInstallScript: "post install script",
|
||||
Filename: "ruby.deb",
|
||||
Title: "ruby",
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
||||
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
|
||||
|
||||
latestInstallUUID := func() string {
|
||||
var id string
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// create install request for the software and record a successful result
|
||||
resp := installSoftwareResponse{}
|
||||
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &resp)
|
||||
installUUID := latestInstallUUID()
|
||||
|
||||
s.Do("POST", "/api/fleet/orbit/software_install/result",
|
||||
json.RawMessage(fmt.Sprintf(`{
|
||||
"orbit_node_key": %q,
|
||||
"install_uuid": %q,
|
||||
"pre_install_condition_output": "",
|
||||
"install_script_exit_code": 0,
|
||||
"install_script_output": "success"
|
||||
}`, *host.OrbitNodeKey, installUUID)),
|
||||
http.StatusNoContent)
|
||||
|
||||
// Software is now installed on the host. We should see it in the host software list
|
||||
getHostSw := getHostSoftwareResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
|
||||
require.Len(t, getHostSw.Software, 1)
|
||||
require.Equal(t, getHostSw.Software[0].Name, "ruby")
|
||||
|
||||
// De-scope the software by adding an exclude any label that the host has.
|
||||
// TODO(JVE): remove/update this once the API is in place
|
||||
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
|
||||
siID, labelID, exclude,
|
||||
)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
var installerID uint
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
|
||||
})
|
||||
require.NotEmpty(t, installerID)
|
||||
|
||||
// create some labels and assign them to the host
|
||||
var labelResp createLabelResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: "label1",
|
||||
Hosts: []string{host.Hostname},
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
lbl1 := labelResp.Label
|
||||
|
||||
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
|
||||
Name: "label2",
|
||||
Query: "SELECT 1",
|
||||
}}, http.StatusOK, &labelResp)
|
||||
require.NotZero(t, labelResp.Label.ID)
|
||||
lbl2 := labelResp.Label
|
||||
err := s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
updateInstallerLabel(installerID, lbl1.ID, true)
|
||||
updateInstallerLabel(installerID, lbl2.ID, true)
|
||||
|
||||
// We should still see the software at this point, because we haven't uninstalled it yet
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
|
||||
require.Len(t, getHostSw.Software, 1)
|
||||
|
||||
// uninstall the software
|
||||
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", host.ID, titleID), nil, http.StatusAccepted, &resp)
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
|
||||
require.Len(t, getHostSw.Software, 1)
|
||||
assert.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastInstall)
|
||||
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSw.Software[0].Status)
|
||||
require.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastUninstall)
|
||||
uninstallExecutionID := getHostSw.Software[0].SoftwarePackage.LastUninstall.ExecutionID
|
||||
|
||||
// Host sends failed uninstall result
|
||||
var orbitPostScriptResp orbitPostScriptResultResponse
|
||||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "uninstall"}`, *host.OrbitNodeKey,
|
||||
uninstallExecutionID)),
|
||||
http.StatusOK, &orbitPostScriptResp)
|
||||
|
||||
// Now that the software is uninstalled, we should no longer see it in the host software list,
|
||||
// because it is de-scoped via labels.
|
||||
getHostSw = getHostSoftwareResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
|
||||
require.Empty(t, getHostSw.Software)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4839,10 +4839,12 @@ func generateMultipartRequest(t *testing.T,
|
|||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// add file content
|
||||
ff, err := writer.CreateFormFile(uploadFileField, fileName)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(ff, bytes.NewReader(fileContent))
|
||||
require.NoError(t, err)
|
||||
if fileName != "" || len(fileContent) > 0 {
|
||||
ff, err := writer.CreateFormFile(uploadFileField, fileName)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(ff, bytes.NewReader(fileContent))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// add extra fields
|
||||
for key, values := range extraFields {
|
||||
|
|
@ -4852,7 +4854,7 @@ func generateMultipartRequest(t *testing.T,
|
|||
}
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
err := writer.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
headers := map[string]string{
|
||||
|
|
@ -12179,6 +12181,7 @@ func (s *integrationMDMTestSuite) TestSetupExperience() {
|
|||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
Platform: string(fleet.MacOSPlatform),
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
_ = installerID1
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
|
|
@ -649,3 +651,38 @@ func (svc *Service) GetLabelSpec(ctx context.Context, name string) (*fleet.Label
|
|||
|
||||
return svc.ds.GetLabelSpec(ctx, name)
|
||||
}
|
||||
|
||||
func (svc *Service) BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]fleet.LabelIdent, error) {
|
||||
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
|
||||
return nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
|
||||
} else if !authctx.Checked() {
|
||||
return nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
|
||||
}
|
||||
|
||||
if len(labelNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
uniqueNames := server.RemoveDuplicatesFromSlice(labelNames)
|
||||
|
||||
labels, err := svc.ds.LabelIDsByName(ctx, uniqueNames)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
|
||||
}
|
||||
|
||||
if len(labels) != len(uniqueNames) {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: "some or all the labels provided don't exist",
|
||||
InternalErr: fmt.Errorf("names provided: %v", labelNames),
|
||||
}
|
||||
}
|
||||
|
||||
byName := make(map[string]fleet.LabelIdent, len(labels))
|
||||
for labelName, labelID := range labels {
|
||||
byName[labelName] = fleet.LabelIdent{
|
||||
LabelName: labelName,
|
||||
LabelID: labelID,
|
||||
}
|
||||
}
|
||||
return byName, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
|
@ -312,3 +313,108 @@ func TestLabelsWithReplica(t *testing.T) {
|
|||
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
|
||||
require.Equal(t, 1, lbl.HostCount)
|
||||
}
|
||||
|
||||
func TestBatchValidateLabels(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
svc, ctx := newTestService(t, ds, nil, nil)
|
||||
|
||||
t.Run("no auth context", func(t *testing.T) {
|
||||
_, err := svc.BatchValidateLabels(context.Background(), nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
authCtx := authz_ctx.AuthorizationContext{}
|
||||
ctx = authz_ctx.NewContext(ctx, &authCtx)
|
||||
|
||||
t.Run("no auth checked", func(t *testing.T) {
|
||||
_, err := svc.BatchValidateLabels(ctx, nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
// validator requires that an authz check has been performed upstream so we'll set it now for
|
||||
// the rest of the tests
|
||||
authCtx.SetChecked()
|
||||
|
||||
mockLabels := map[string]uint{
|
||||
"foo": 1,
|
||||
"bar": 2,
|
||||
"baz": 3,
|
||||
}
|
||||
|
||||
mockLabelIdent := func(name string, id uint) fleet.LabelIdent {
|
||||
return fleet.LabelIdent{LabelID: id, LabelName: name}
|
||||
}
|
||||
|
||||
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
|
||||
res := make(map[string]uint)
|
||||
if names == nil {
|
||||
return res, nil
|
||||
}
|
||||
for _, name := range names {
|
||||
if id, ok := mockLabels[name]; ok {
|
||||
res[name] = id
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
labelNames []string
|
||||
expectLabels map[string]fleet.LabelIdent
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
"no labels",
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"include labels",
|
||||
[]string{"foo", "bar"},
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": mockLabelIdent("foo", 1),
|
||||
"bar": mockLabelIdent("bar", 2),
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"non-existent label",
|
||||
[]string{"foo", "qux"},
|
||||
nil,
|
||||
"some or all the labels provided don't exist",
|
||||
},
|
||||
{
|
||||
"duplicate label",
|
||||
[]string{"foo", "foo"},
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": mockLabelIdent("foo", 1),
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty slice",
|
||||
[]string{},
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty string",
|
||||
[]string{""},
|
||||
nil,
|
||||
"some or all the labels provided don't exist",
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := svc.BatchValidateLabels(ctx, tt.labelNames)
|
||||
if tt.expectError != "" {
|
||||
require.Contains(t, err.Error(), tt.expectError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectLabels, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ import (
|
|||
)
|
||||
|
||||
type addFleetMaintainedAppRequest struct {
|
||||
TeamID *uint `json:"team_id"`
|
||||
AppID uint `json:"fleet_maintained_app_id"`
|
||||
InstallScript string `json:"install_script"`
|
||||
PreInstallQuery string `json:"pre_install_query"`
|
||||
PostInstallScript string `json:"post_install_script"`
|
||||
SelfService bool `json:"self_service"`
|
||||
UninstallScript string `json:"uninstall_script"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
AppID uint `json:"fleet_maintained_app_id"`
|
||||
InstallScript string `json:"install_script"`
|
||||
PreInstallQuery string `json:"pre_install_query"`
|
||||
PostInstallScript string `json:"post_install_script"`
|
||||
SelfService bool `json:"self_service"`
|
||||
UninstallScript string `json:"uninstall_script"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
}
|
||||
|
||||
type addFleetMaintainedAppResponse struct {
|
||||
|
|
@ -39,6 +41,8 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
|||
req.PostInstallScript,
|
||||
req.UninstallScript,
|
||||
req.SelfService,
|
||||
req.LabelsIncludeAny,
|
||||
req.LabelsExcludeAny,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
|
@ -50,7 +54,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
|||
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) {
|
||||
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
|
@ -58,6 +62,24 @@ func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, app
|
|||
return 0, fleet.ErrMissingLicense
|
||||
}
|
||||
|
||||
type editFleetMaintainedAppRequest struct {
|
||||
TeamID *uint `json:"team_id"`
|
||||
AppID uint `json:"fleet_maintained_app_id"`
|
||||
InstallScript string `json:"install_script"`
|
||||
PreInstallQuery string `json:"pre_install_query"`
|
||||
PostInstallScript string `json:"post_install_script"`
|
||||
SelfService bool `json:"self_service"`
|
||||
UninstallScript string `json:"uninstall_script"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
}
|
||||
|
||||
func editFleetMaintainedAppEndpoint(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
|
||||
// TODO: implement this
|
||||
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
type listFleetMaintainedAppsRequest struct {
|
||||
fleet.ListOptions
|
||||
TeamID *uint `query:"team_id,optional"`
|
||||
|
|
|
|||
|
|
@ -1010,6 +1010,8 @@ func (svc *Service) SubmitDistributedQueryResults(
|
|||
logging.WithErr(ctx, err)
|
||||
}
|
||||
|
||||
// NOTE: if the installers for the policies here are not scoped to the host via labels, we update the policy status here to stop it from showing up as "failed" in the
|
||||
// host details.
|
||||
if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
}
|
||||
|
|
@ -1795,6 +1797,17 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
|
|||
level.Debug(logger).Log("msg", "installer platform does not match host platform")
|
||||
continue
|
||||
}
|
||||
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, failingPolicyWithInstaller.InstallerID, hostID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking if software installer is label scoped to host")
|
||||
}
|
||||
if !scoped {
|
||||
// NOTE: we update the policy status here to stop it from showing up as "failed" in the
|
||||
// host details.
|
||||
incomingPolicyResults[failingPolicyWithInstaller.ID] = nil
|
||||
level.Debug(logger).Log("msg", "not marking policy as failed since software is out of scope for host")
|
||||
continue
|
||||
}
|
||||
hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get host last install data")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ type uploadSoftwareInstallerRequest struct {
|
|||
PostInstallScript string
|
||||
SelfService bool
|
||||
UninstallScript string
|
||||
LabelsIncludeAny []string
|
||||
LabelsExcludeAny []string
|
||||
}
|
||||
|
||||
type updateSoftwareInstallerRequest struct {
|
||||
|
|
@ -41,6 +43,8 @@ type updateSoftwareInstallerRequest struct {
|
|||
PostInstallScript *string
|
||||
UninstallScript *string
|
||||
SelfService *bool
|
||||
LabelsIncludeAny []string
|
||||
LabelsExcludeAny []string
|
||||
}
|
||||
|
||||
type uploadSoftwareInstallerResponse struct {
|
||||
|
|
@ -131,6 +135,30 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
|
|||
decoded.SelfService = &parsed
|
||||
}
|
||||
|
||||
// decode labels
|
||||
var inclAny, exclAny []string
|
||||
var existsInclAny, existsExclAny bool
|
||||
|
||||
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
|
||||
switch {
|
||||
case !existsInclAny:
|
||||
decoded.LabelsIncludeAny = nil
|
||||
case len(inclAny) == 1 && inclAny[0] == "":
|
||||
decoded.LabelsIncludeAny = []string{}
|
||||
default:
|
||||
decoded.LabelsIncludeAny = inclAny
|
||||
}
|
||||
|
||||
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
|
||||
switch {
|
||||
case !existsExclAny:
|
||||
decoded.LabelsExcludeAny = nil
|
||||
case len(exclAny) == 1 && exclAny[0] == "":
|
||||
decoded.LabelsExcludeAny = []string{}
|
||||
default:
|
||||
decoded.LabelsExcludeAny = exclAny
|
||||
}
|
||||
|
||||
return &decoded, nil
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +173,8 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
|
|||
PostInstallScript: req.PostInstallScript,
|
||||
UninstallScript: req.UninstallScript,
|
||||
SelfService: req.SelfService,
|
||||
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||
}
|
||||
if req.File != nil {
|
||||
ff, err := req.File.Open()
|
||||
|
|
@ -261,6 +291,31 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
|
|||
decoded.SelfService = parsed
|
||||
}
|
||||
|
||||
// decode labels
|
||||
// decode labels
|
||||
var inclAny, exclAny []string
|
||||
var existsInclAny, existsExclAny bool
|
||||
|
||||
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
|
||||
switch {
|
||||
case !existsInclAny:
|
||||
decoded.LabelsIncludeAny = nil
|
||||
case len(inclAny) == 1 && inclAny[0] == "":
|
||||
decoded.LabelsIncludeAny = []string{}
|
||||
default:
|
||||
decoded.LabelsIncludeAny = inclAny
|
||||
}
|
||||
|
||||
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
|
||||
switch {
|
||||
case !existsExclAny:
|
||||
decoded.LabelsExcludeAny = nil
|
||||
case len(exclAny) == 1 && exclAny[0] == "":
|
||||
decoded.LabelsExcludeAny = []string{}
|
||||
default:
|
||||
decoded.LabelsExcludeAny = exclAny
|
||||
}
|
||||
|
||||
return &decoded, nil
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +344,8 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
|
|||
Filename: req.File.Filename,
|
||||
SelfService: req.SelfService,
|
||||
UninstallScript: req.UninstallScript,
|
||||
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||
}
|
||||
|
||||
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
|
||||
|
|
@ -549,9 +606,9 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st
|
|||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type batchSetSoftwareInstallersRequest struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type batchSetSoftwareInstallersResponse struct {
|
||||
|
|
@ -571,7 +628,7 @@ func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}
|
|||
return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
|
||||
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
|
|
@ -116,7 +118,8 @@ func TestSoftwareInstallersAuth(t *testing.T) {
|
|||
}
|
||||
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
|
||||
_ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
_ sqlx.QueryerContext,
|
||||
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil
|
||||
}
|
||||
|
||||
|
|
@ -154,3 +157,293 @@ func TestSoftwareInstallersAuth(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSoftwareInstallerLabels(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license})
|
||||
|
||||
t.Run("validate no update", func(t *testing.T) {
|
||||
t.Run("no auth context", func(t *testing.T) {
|
||||
_, err := eeservice.ValidateSoftwareLabels(context.Background(), svc, nil, nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
authCtx := authz_ctx.AuthorizationContext{}
|
||||
ctx = authz_ctx.NewContext(ctx, &authCtx)
|
||||
|
||||
t.Run("no auth checked", func(t *testing.T) {
|
||||
_, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
// validator requires that an authz check has been performed upstream so we'll set it now for
|
||||
// the rest of the tests
|
||||
authCtx.SetChecked()
|
||||
|
||||
mockLabels := map[string]uint{
|
||||
"foo": 1,
|
||||
"bar": 2,
|
||||
"baz": 3,
|
||||
}
|
||||
|
||||
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
|
||||
res := make(map[string]uint)
|
||||
if names == nil {
|
||||
return res, nil
|
||||
}
|
||||
for _, name := range names {
|
||||
if id, ok := mockLabels[name]; ok {
|
||||
res[name] = id
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
payloadIncludeAny []string
|
||||
payloadExcludeAny []string
|
||||
expectLabels map[string]fleet.LabelIdent
|
||||
expectScope fleet.LabelScope
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
"no labels",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"include labels",
|
||||
[]string{"foo", "bar"},
|
||||
nil,
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": {LabelID: 1, LabelName: "foo"},
|
||||
"bar": {LabelID: 2, LabelName: "bar"},
|
||||
},
|
||||
fleet.LabelScopeIncludeAny,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"exclude labels",
|
||||
nil,
|
||||
[]string{"bar", "baz"},
|
||||
map[string]fleet.LabelIdent{
|
||||
"bar": {LabelID: 2, LabelName: "bar"},
|
||||
"baz": {LabelID: 3, LabelName: "baz"},
|
||||
},
|
||||
fleet.LabelScopeExcludeAny,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"include and exclude labels",
|
||||
[]string{"foo"},
|
||||
[]string{"bar"},
|
||||
nil,
|
||||
"",
|
||||
`Only one of "labels_include_any" or "labels_exclude_any" can be included.`,
|
||||
},
|
||||
{
|
||||
"non-existent label",
|
||||
[]string{"foo", "qux"},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
"some or all the labels provided don't exist",
|
||||
},
|
||||
{
|
||||
"duplicate label",
|
||||
[]string{"foo", "foo"},
|
||||
nil,
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": {LabelID: 1, LabelName: "foo"},
|
||||
},
|
||||
fleet.LabelScopeIncludeAny,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty slice",
|
||||
nil,
|
||||
[]string{},
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty string",
|
||||
nil,
|
||||
[]string{""},
|
||||
nil,
|
||||
"",
|
||||
"some or all the labels provided don't exist",
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := eeservice.ValidateSoftwareLabels(ctx, svc, tt.payloadIncludeAny, tt.payloadExcludeAny)
|
||||
if tt.expectError != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, tt.expectScope, got.LabelScope)
|
||||
require.Equal(t, tt.expectLabels, got.ByName)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate update", func(t *testing.T) {
|
||||
t.Run("no auth context", func(t *testing.T) {
|
||||
_, _, err := eeservice.ValidateSoftwareLabelsForUpdate(context.Background(), svc, nil, nil, nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
authCtx := authz_ctx.AuthorizationContext{}
|
||||
ctx = authz_ctx.NewContext(ctx, &authCtx)
|
||||
|
||||
t.Run("no auth checked", func(t *testing.T) {
|
||||
_, _, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, nil, nil, nil)
|
||||
require.ErrorContains(t, err, "Authentication required")
|
||||
})
|
||||
|
||||
// validator requires that an authz check has been performed upstream so we'll set it now for
|
||||
// the rest of the tests
|
||||
authCtx.SetChecked()
|
||||
|
||||
mockLabels := map[string]uint{
|
||||
"foo": 1,
|
||||
"bar": 2,
|
||||
"baz": 3,
|
||||
}
|
||||
|
||||
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
|
||||
res := make(map[string]uint)
|
||||
if names == nil {
|
||||
return res, nil
|
||||
}
|
||||
for _, name := range names {
|
||||
if id, ok := mockLabels[name]; ok {
|
||||
res[name] = id
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
existingInstaller *fleet.SoftwareInstaller
|
||||
payloadIncludeAny []string
|
||||
payloadExcludeAny []string
|
||||
shouldUpdate bool
|
||||
expectLabels map[string]fleet.LabelIdent
|
||||
expectScope fleet.LabelScope
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
"no installer",
|
||||
nil,
|
||||
nil,
|
||||
[]string{"foo"},
|
||||
false,
|
||||
nil,
|
||||
"",
|
||||
"existing installer must be provided",
|
||||
},
|
||||
{
|
||||
"no labels",
|
||||
&fleet.SoftwareInstaller{},
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"add label",
|
||||
&fleet.SoftwareInstaller{
|
||||
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
|
||||
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
|
||||
},
|
||||
[]string{"foo", "bar"},
|
||||
nil,
|
||||
true,
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": {LabelID: 1, LabelName: "foo"},
|
||||
"bar": {LabelID: 2, LabelName: "bar"},
|
||||
},
|
||||
fleet.LabelScopeIncludeAny,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"change scope",
|
||||
&fleet.SoftwareInstaller{
|
||||
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
|
||||
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
|
||||
},
|
||||
nil,
|
||||
[]string{"foo"},
|
||||
true,
|
||||
map[string]fleet.LabelIdent{
|
||||
"foo": {LabelID: 1, LabelName: "foo"},
|
||||
},
|
||||
fleet.LabelScopeExcludeAny,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"remove label",
|
||||
&fleet.SoftwareInstaller{
|
||||
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
|
||||
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
|
||||
},
|
||||
[]string{},
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"no change",
|
||||
&fleet.SoftwareInstaller{
|
||||
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
|
||||
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
|
||||
},
|
||||
[]string{"foo"},
|
||||
nil,
|
||||
false,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
shouldUpate, got, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, tt.existingInstaller, tt.payloadIncludeAny, tt.payloadExcludeAny)
|
||||
if tt.expectError != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tt.shouldUpdate {
|
||||
require.True(t, shouldUpate)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, tt.expectScope, got.LabelScope)
|
||||
require.Equal(t, tt.expectLabels, got.ByName)
|
||||
} else {
|
||||
require.False(t, shouldUpate)
|
||||
require.Nil(t, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,24 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
|
|||
require.NoError(t, ts.ds.DeleteHost(ctx, host.ID))
|
||||
}
|
||||
|
||||
teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
for _, tm := range teams {
|
||||
err := ts.ds.DeleteTeam(ctx, tm.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
|
||||
return err
|
||||
})
|
||||
|
||||
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
|
||||
return err
|
||||
})
|
||||
|
||||
lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
for _, lbl := range lbls {
|
||||
|
|
@ -152,24 +170,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
for _, tm := range teams {
|
||||
err := ts.ds.DeleteTeam(ctx, tm.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
|
||||
return err
|
||||
})
|
||||
|
||||
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
|
||||
return err
|
||||
})
|
||||
|
||||
// Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`)
|
||||
|
|
@ -587,6 +587,16 @@ func (ts *withServer) uploadSoftwareInstaller(
|
|||
if payload.SelfService {
|
||||
require.NoError(t, w.WriteField("self_service", "true"))
|
||||
}
|
||||
if payload.LabelsIncludeAny != nil {
|
||||
for _, l := range payload.LabelsIncludeAny {
|
||||
require.NoError(t, w.WriteField("labels_include_any", l))
|
||||
}
|
||||
}
|
||||
if payload.LabelsExcludeAny != nil {
|
||||
for _, l := range payload.LabelsExcludeAny {
|
||||
require.NoError(t, w.WriteField("labels_exclude_any", l))
|
||||
}
|
||||
}
|
||||
|
||||
w.Close()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue